USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
2826 lines | 102.045kb
Bogdan Timofte authored 2 months ago
1
//
2
//  MeasurementChartView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 06/05/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10

            
Bogdan Timofte authored 2 months ago
11
private enum PresentTrackingMode: CaseIterable, Hashable {
12
    case keepDuration
13
    case keepStartTimestamp
14
}
15

            
Bogdan Timofte authored 2 months ago
16
struct MeasurementChartView: View {
Bogdan Timofte authored 2 months 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 2 months ago
51
    private enum SeriesKind {
52
        case power
Bogdan Timofte authored 2 months ago
53
        case energy
Bogdan Timofte authored 2 months ago
54
        case voltage
55
        case current
Bogdan Timofte authored 2 months ago
56
        case temperature
Bogdan Timofte authored 2 months ago
57

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

            
68
        var tint: Color {
69
            switch self {
70
            case .power: return .red
Bogdan Timofte authored 2 months ago
71
            case .energy: return .teal
Bogdan Timofte authored 2 months ago
72
            case .voltage: return .green
73
            case .current: return .blue
Bogdan Timofte authored 2 months ago
74
            case .temperature: return .orange
Bogdan Timofte authored 2 months 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 months ago
89
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 months ago
90
    private let minimumVoltageSpan = 0.5
91
    private let minimumCurrentSpan = 0.5
92
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 2 months ago
93
    private let minimumEnergySpan = 0.1
Bogdan Timofte authored 2 months ago
94
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored 2 months ago
95
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
Bogdan Timofte authored 2 months ago
96
    private let selectorTint: Color = .blue
Bogdan Timofte authored 2 months ago
97

            
98
    let compactLayout: Bool
99
    let availableSize: CGSize
Bogdan Timofte authored a month ago
100
    let showsRangeSelector: Bool
101
    let rebasesEnergyToVisibleRangeStart: Bool
Bogdan Timofte authored 2 months ago
102

            
103
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 months ago
104
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
105
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 months ago
106
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
107

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

            
Bogdan Timofte authored 2 months ago
130
    init(
131
        compactLayout: Bool = false,
132
        availableSize: CGSize = .zero,
Bogdan Timofte authored a month ago
133
        timeRange: ClosedRange<Date>? = nil,
134
        showsRangeSelector: Bool = true,
135
        rebasesEnergyToVisibleRangeStart: Bool = false
Bogdan Timofte authored 2 months ago
136
    ) {
137
        self.compactLayout = compactLayout
138
        self.availableSize = availableSize
139
        self.timeRange = timeRange
Bogdan Timofte authored a month ago
140
        self.showsRangeSelector = showsRangeSelector
141
        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
Bogdan Timofte authored 2 months ago
142
    }
143

            
144
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 2 months ago
145
        if compactLayout {
146
            return 38
147
        }
148
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored 2 months ago
149
    }
150

            
151
    private var chartSectionSpacing: CGFloat {
152
        compactLayout ? 6 : 8
153
    }
154

            
155
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 2 months ago
156
        if compactLayout {
157
            return 24
158
        }
159
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored 2 months ago
160
    }
161

            
Bogdan Timofte authored a month ago
162
    private var belowXAxisControlsHeight: CGFloat {
163
        if usesCompactLandscapeOriginControls {
164
            return 40
165
        }
166
        if compactLayout {
167
            return 46
168
        }
169
        return isLargeDisplay ? 58 : 50
170
    }
171

            
Bogdan Timofte authored 2 months ago
172
    private var isPortraitLayout: Bool {
173
        guard availableSize != .zero else { return verticalSizeClass != .compact }
174
        return availableSize.height >= availableSize.width
175
    }
176

            
Bogdan Timofte authored 2 months ago
177
    private var isIPhone: Bool {
178
        #if os(iOS)
179
        return UIDevice.current.userInterfaceIdiom == .phone
180
        #else
181
        return false
182
        #endif
183
    }
184

            
185
    private enum OriginControlsPlacement {
186
        case aboveXAxisLegend
187
        case overXAxisLegend
188
        case belowXAxisLegend
189
    }
190

            
191
    private var originControlsPlacement: OriginControlsPlacement {
192
        if isIPhone {
193
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
194
        }
195
        return .belowXAxisLegend
196
    }
197

            
Bogdan Timofte authored 2 months ago
198
    private var plotSectionHeight: CGFloat {
199
        if availableSize == .zero {
Bogdan Timofte authored 2 months ago
200
            return compactLayout ? 300 : 380
201
        }
202

            
203
        if isPortraitLayout {
204
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
205
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
206
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored 2 months ago
207
        }
208

            
209
        if compactLayout {
210
            return min(max(availableSize.height * 0.36, 240), 300)
211
        }
212

            
213
        return min(max(availableSize.height * 0.5, 300), 440)
214
    }
215

            
216
    private var stackedToolbarLayout: Bool {
217
        if availableSize.width > 0 {
218
            return availableSize.width < 640
219
        }
220

            
221
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
222
    }
223

            
224
    private var showsLabeledOriginControls: Bool {
225
        !compactLayout && !stackedToolbarLayout
226
    }
227

            
Bogdan Timofte authored 2 months ago
228
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 2 months ago
229
        #if os(iOS)
230
        if UIDevice.current.userInterfaceIdiom == .phone {
231
            return false
232
        }
233
        #endif
234

            
Bogdan Timofte authored 2 months ago
235
        if availableSize.width > 0 {
236
            return availableSize.width >= 900 || availableSize.height >= 700
237
        }
238
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
239
    }
240

            
241
    private var chartBaseFont: Font {
Bogdan Timofte authored 2 months ago
242
        if isIPhone && isPortraitLayout {
243
            return .caption
244
        }
245
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 2 months ago
246
    }
247

            
Bogdan Timofte authored 2 months ago
248
    private var usesCompactLandscapeOriginControls: Bool {
249
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
250
    }
251

            
Bogdan Timofte authored 2 months ago
252
    var body: some View {
Bogdan Timofte authored 2 months ago
253
        let availableTimeRange = availableSelectionTimeRange()
254
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
255
        let powerSeries = series(
256
            for: measurements.power,
257
            kind: .power,
258
            minimumYSpan: minimumPowerSpan,
259
            visibleTimeRange: visibleTimeRange
260
        )
Bogdan Timofte authored 2 months ago
261
        let energySeries = series(
262
            for: measurements.energy,
263
            kind: .energy,
264
            minimumYSpan: minimumEnergySpan,
265
            visibleTimeRange: visibleTimeRange
266
        )
Bogdan Timofte authored 2 months ago
267
        let voltageSeries = series(
268
            for: measurements.voltage,
269
            kind: .voltage,
270
            minimumYSpan: minimumVoltageSpan,
271
            visibleTimeRange: visibleTimeRange
272
        )
273
        let currentSeries = series(
274
            for: measurements.current,
275
            kind: .current,
276
            minimumYSpan: minimumCurrentSpan,
277
            visibleTimeRange: visibleTimeRange
278
        )
279
        let temperatureSeries = series(
280
            for: measurements.temperature,
281
            kind: .temperature,
282
            minimumYSpan: minimumTemperatureSpan,
283
            visibleTimeRange: visibleTimeRange
284
        )
Bogdan Timofte authored 2 months ago
285
        let primarySeries = displayedPrimarySeries(
286
            powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
287
            energySeries: energySeries,
Bogdan Timofte authored 2 months ago
288
            voltageSeries: voltageSeries,
289
            currentSeries: currentSeries
290
        )
Bogdan Timofte authored 2 months ago
291
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 months ago
292

            
Bogdan Timofte authored 2 months ago
293
        Group {
Bogdan Timofte authored 2 months ago
294
            if let primarySeries {
295
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
296
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
297

            
298
                    GeometryReader { geometry in
Bogdan Timofte authored a month ago
299
                        let reservedBottomHeight =
300
                            xAxisHeight
301
                            + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
302
                        let plotHeight = max(
303
                            geometry.size.height - reservedBottomHeight,
304
                            compactLayout ? 180 : 220
305
                        )
Bogdan Timofte authored 2 months ago
306

            
307
                        VStack(spacing: 6) {
308
                            HStack(spacing: chartSectionSpacing) {
309
                                primaryAxisView(
310
                                    height: plotHeight,
311
                                    powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
312
                                    energySeries: energySeries,
Bogdan Timofte authored 2 months ago
313
                                    voltageSeries: voltageSeries,
314
                                    currentSeries: currentSeries
315
                                )
316
                                .frame(width: axisColumnWidth, height: plotHeight)
317

            
318
                                ZStack {
319
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
320
                                        .fill(Color.primary.opacity(0.05))
321

            
322
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
323
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
324

            
325
                                    horizontalGuides(context: primarySeries.context)
326
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored 2 months ago
327
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 months ago
328
                                    renderedChart(
329
                                        powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
330
                                        energySeries: energySeries,
Bogdan Timofte authored 2 months ago
331
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
332
                                        currentSeries: currentSeries,
333
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
334
                                    )
Bogdan Timofte authored 2 months ago
335
                                }
Bogdan Timofte authored 2 months ago
336
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
337
                                .frame(maxWidth: .infinity)
338
                                .frame(height: plotHeight)
339

            
340
                                secondaryAxisView(
341
                                    height: plotHeight,
342
                                    powerSeries: powerSeries,
Bogdan Timofte authored 2 months ago
343
                                    energySeries: energySeries,
Bogdan Timofte authored 2 months ago
344
                                    voltageSeries: voltageSeries,
Bogdan Timofte authored 2 months ago
345
                                    currentSeries: currentSeries,
346
                                    temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 months ago
347
                                )
348
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 months ago
349
                            }
Bogdan Timofte authored 2 months ago
350
                            .overlay(alignment: .bottom) {
Bogdan Timofte authored 2 months ago
351
                                if originControlsPlacement == .aboveXAxisLegend {
352
                                    scaleControlsPill(
353
                                        voltageSeries: voltageSeries,
354
                                        currentSeries: currentSeries
355
                                    )
356
                                    .padding(.bottom, compactLayout ? 6 : 10)
357
                                }
Bogdan Timofte authored 2 months ago
358
                            }
Bogdan Timofte authored 2 months ago
359

            
Bogdan Timofte authored 2 months ago
360
                            switch originControlsPlacement {
361
                            case .aboveXAxisLegend:
362
                                xAxisLabelsView(context: primarySeries.context)
363
                                    .frame(height: xAxisHeight)
364
                            case .overXAxisLegend:
365
                                xAxisLabelsView(context: primarySeries.context)
366
                                    .frame(height: xAxisHeight)
367
                                    .overlay(alignment: .center) {
368
                                        scaleControlsPill(
369
                                            voltageSeries: voltageSeries,
370
                                            currentSeries: currentSeries
371
                                        )
Bogdan Timofte authored 2 months ago
372
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 2 months ago
373
                                    }
374
                            case .belowXAxisLegend:
375
                                xAxisLabelsView(context: primarySeries.context)
376
                                    .frame(height: xAxisHeight)
377

            
378
                                HStack {
379
                                    Spacer(minLength: 0)
380
                                    scaleControlsPill(
381
                                        voltageSeries: voltageSeries,
382
                                        currentSeries: currentSeries
383
                                    )
384
                                    Spacer(minLength: 0)
385
                                }
386
                            }
Bogdan Timofte authored 2 months ago
387

            
Bogdan Timofte authored a month ago
388
                            if showsRangeSelector,
389
                               let availableTimeRange,
Bogdan Timofte authored 2 months ago
390
                               let selectorSeries,
391
                               shouldShowRangeSelector(
392
                                availableTimeRange: availableTimeRange,
393
                                series: selectorSeries
394
                               ) {
395
                                TimeRangeSelectorView(
396
                                    points: selectorSeries.points,
397
                                    context: selectorSeries.context,
398
                                    availableTimeRange: availableTimeRange,
Bogdan Timofte authored 2 months ago
399
                                    selectorTint: selectorTint,
Bogdan Timofte authored 2 months ago
400
                                    compactLayout: compactLayout,
401
                                    minimumSelectionSpan: minimumTimeSpan,
Bogdan Timofte authored 2 months ago
402
                                    onKeepSelection: trimBufferToSelection,
403
                                    onRemoveSelection: removeSelectionFromBuffer,
404
                                    onResetBuffer: resetBuffer,
Bogdan Timofte authored 2 months ago
405
                                    selectedTimeRange: $selectedVisibleTimeRange,
406
                                    isPinnedToPresent: $isPinnedToPresent,
407
                                    presentTrackingMode: $presentTrackingMode
408
                                )
409
                            }
Bogdan Timofte authored 2 months ago
410
                        }
Bogdan Timofte authored 2 months ago
411
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 months ago
412
                    }
Bogdan Timofte authored 2 months ago
413
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 months ago
414
                }
Bogdan Timofte authored 2 months ago
415
            } else {
416
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 2 months ago
417
                    chartToggleBar()
Bogdan Timofte authored 2 months ago
418
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 months ago
419
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 months ago
420
                }
421
            }
Bogdan Timofte authored 2 months ago
422
        }
Bogdan Timofte authored 2 months ago
423
        .font(chartBaseFont)
Bogdan Timofte authored 2 months ago
424
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
425
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
426
            guard timeRange == nil else { return }
427
            chartNow = now
428
        }
Bogdan Timofte authored 2 months ago
429
    }
430

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

            
Bogdan Timofte authored 2 months ago
435
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored 2 months ago
436
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 months ago
437
        }
438
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
439
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
440
        .background(
441
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
442
                .fill(Color.primary.opacity(0.045))
443
        )
444
        .overlay(
445
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
446
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
447
        )
Bogdan Timofte authored 2 months ago
448

            
Bogdan Timofte authored 2 months ago
449
        return Group {
Bogdan Timofte authored 2 months ago
450
            if stackedToolbarLayout {
Bogdan Timofte authored 2 months ago
451
                controlsPanel
Bogdan Timofte authored 2 months ago
452
            } else {
Bogdan Timofte authored 2 months ago
453
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
454
                    controlsPanel
Bogdan Timofte authored 2 months ago
455
                }
Bogdan Timofte authored 2 months ago
456
            }
457
        }
458
        .frame(maxWidth: .infinity, alignment: .leading)
459
    }
460

            
Bogdan Timofte authored 2 months ago
461
    private var shouldFloatScaleControlsOverChart: Bool {
462
        #if os(iOS)
463
        if availableSize.width > 0, availableSize.height > 0 {
464
            return availableSize.width > availableSize.height
465
        }
466
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
467
        #else
468
        return false
469
        #endif
470
    }
471

            
472
    private func scaleControlsPill(
473
        voltageSeries: SeriesData,
474
        currentSeries: SeriesData
475
    ) -> some View {
476
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 2 months ago
477
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 2 months ago
478
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
479
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 2 months ago
480

            
481
        return originControlsRow(
482
            voltageSeries: voltageSeries,
483
            currentSeries: currentSeries,
484
            condensedLayout: condensedLayout,
485
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
486
        )
Bogdan Timofte authored 2 months ago
487
        .padding(.horizontal, horizontalPadding)
488
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 2 months ago
489
        .background(
490
            Capsule(style: .continuous)
Bogdan Timofte authored 2 months ago
491
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 2 months ago
492
        )
493
        .overlay(
494
            Capsule(style: .continuous)
495
                .stroke(
Bogdan Timofte authored 2 months ago
496
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 2 months ago
497
                    lineWidth: 1
498
                )
499
        )
500
    }
501

            
Bogdan Timofte authored 2 months ago
502
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
503
        HStack(spacing: condensedLayout ? 6 : 8) {
504
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
505
                displayVoltage.toggle()
506
                if displayVoltage {
507
                    displayPower = false
Bogdan Timofte authored 2 months ago
508
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
509
                    if displayTemperature && displayCurrent {
510
                        displayCurrent = false
511
                    }
Bogdan Timofte authored 2 months ago
512
                }
513
            }
514

            
515
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
516
                displayCurrent.toggle()
517
                if displayCurrent {
518
                    displayPower = false
Bogdan Timofte authored 2 months ago
519
                    displayEnergy = false
Bogdan Timofte authored 2 months ago
520
                    if displayTemperature && displayVoltage {
521
                        displayVoltage = false
522
                    }
Bogdan Timofte authored 2 months ago
523
                }
Bogdan Timofte authored 2 months ago
524
            }
525

            
526
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
527
                displayPower.toggle()
528
                if displayPower {
Bogdan Timofte authored 2 months ago
529
                    displayEnergy = false
530
                    displayCurrent = false
531
                    displayVoltage = false
532
                }
533
            }
534

            
535
            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
536
                displayEnergy.toggle()
537
                if displayEnergy {
538
                    displayPower = false
Bogdan Timofte authored 2 months ago
539
                    displayCurrent = false
540
                    displayVoltage = false
541
                }
542
            }
Bogdan Timofte authored 2 months ago
543

            
544
            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
545
                displayTemperature.toggle()
546
                if displayTemperature && displayVoltage && displayCurrent {
547
                    displayCurrent = false
548
                }
549
            }
Bogdan Timofte authored 2 months ago
550
        }
551
    }
552

            
553
    private func originControlsRow(
554
        voltageSeries: SeriesData,
555
        currentSeries: SeriesData,
Bogdan Timofte authored 2 months ago
556
        condensedLayout: Bool,
557
        showsLabel: Bool
Bogdan Timofte authored 2 months ago
558
    ) -> some View {
Bogdan Timofte authored 2 months ago
559
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
560
            if supportsSharedOrigin {
561
                symbolControlChip(
562
                    systemImage: "equal.circle",
563
                    enabled: true,
564
                    active: useSharedOrigin,
565
                    condensedLayout: condensedLayout,
566
                    showsLabel: showsLabel,
567
                    label: "Match Y Scale",
568
                    accessibilityLabel: "Match Y scale"
569
                ) {
570
                    toggleSharedOrigin(
571
                        voltageSeries: voltageSeries,
572
                        currentSeries: currentSeries
573
                    )
574
                }
Bogdan Timofte authored 2 months ago
575
            }
576

            
577
            symbolControlChip(
578
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
579
                enabled: true,
580
                active: pinOrigin,
581
                condensedLayout: condensedLayout,
Bogdan Timofte authored 2 months ago
582
                showsLabel: showsLabel,
Bogdan Timofte authored 2 months ago
583
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
584
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
585
            ) {
586
                togglePinnedOrigin(
587
                    voltageSeries: voltageSeries,
588
                    currentSeries: currentSeries
589
                )
590
            }
591

            
Bogdan Timofte authored 2 months ago
592
            if !pinnedOriginIsZero {
593
                symbolControlChip(
594
                    systemImage: "0.circle",
595
                    enabled: true,
596
                    active: false,
597
                    condensedLayout: condensedLayout,
598
                    showsLabel: showsLabel,
599
                    label: "Origin 0",
600
                    accessibilityLabel: "Set origin to zero"
601
                ) {
602
                    setVisibleOriginsToZero()
603
                }
Bogdan Timofte authored 2 months ago
604
            }
Bogdan Timofte authored 2 months ago
605

            
Bogdan Timofte authored 2 months ago
606
            smoothingControlChip(
607
                condensedLayout: condensedLayout,
608
                showsLabel: showsLabel
609
            )
610

            
Bogdan Timofte authored 2 months ago
611
        }
612
    }
613

            
Bogdan Timofte authored 2 months ago
614
    private func smoothingControlChip(
615
        condensedLayout: Bool,
616
        showsLabel: Bool
617
    ) -> some View {
618
        Menu {
619
            ForEach(SmoothingLevel.allCases, id: \.self) { level in
620
                Button {
621
                    smoothingLevel = level
622
                } label: {
623
                    if smoothingLevel == level {
624
                        Label(level.label, systemImage: "checkmark")
625
                    } else {
626
                        Text(level.label)
Bogdan Timofte authored 2 months ago
627
                    }
628
                }
Bogdan Timofte authored 2 months ago
629
            }
630
        } label: {
631
            Group {
632
                if showsLabel {
633
                    VStack(alignment: .leading, spacing: 2) {
634
                        Label("Smoothing", systemImage: "waveform.path")
635
                            .font(controlChipFont(condensedLayout: condensedLayout))
636

            
637
                        Text(
Bogdan Timofte authored 2 months ago
638
                            smoothingLevel == .off
Bogdan Timofte authored 2 months ago
639
                            ? "Off"
640
                            : "MA \(smoothingLevel.movingAverageWindowSize)"
Bogdan Timofte authored 2 months ago
641
                        )
Bogdan Timofte authored 2 months ago
642
                        .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
643
                        .foregroundColor(.secondary)
644
                        .monospacedDigit()
645
                    }
646
                    .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
647
                    .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
648
                } else {
649
                    VStack(spacing: 1) {
650
                        Image(systemName: "waveform.path")
651
                            .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
652

            
653
                        Text(smoothingLevel.shortLabel)
654
                            .font(.system(size: usesCompactLandscapeOriginControls ? 8 : 9, weight: .bold))
655
                            .monospacedDigit()
656
                    }
657
                    .frame(
658
                        width: usesCompactLandscapeOriginControls ? 36 : (condensedLayout ? 42 : (isLargeDisplay ? 50 : 44)),
659
                        height: usesCompactLandscapeOriginControls ? 34 : (condensedLayout ? 38 : (isLargeDisplay ? 48 : 42))
660
                    )
661
                }
Bogdan Timofte authored 2 months ago
662
            }
Bogdan Timofte authored 2 months ago
663
            .background(
664
                Capsule(style: .continuous)
665
                    .fill(smoothingLevel == .off ? Color.secondary.opacity(0.10) : Color.blue.opacity(0.14))
666
            )
667
            .overlay(
668
                Capsule(style: .continuous)
669
                    .stroke(
670
                        smoothingLevel == .off ? Color.secondary.opacity(0.18) : Color.blue.opacity(0.24),
671
                        lineWidth: 1
672
                    )
673
            )
Bogdan Timofte authored 2 months ago
674
        }
Bogdan Timofte authored 2 months ago
675
        .buttonStyle(.plain)
676
        .foregroundColor(smoothingLevel == .off ? .primary : .blue)
Bogdan Timofte authored 2 months ago
677
    }
678

            
Bogdan Timofte authored 2 months ago
679
    private func seriesToggleButton(
680
        title: String,
681
        isOn: Bool,
682
        condensedLayout: Bool,
683
        action: @escaping () -> Void
684
    ) -> some View {
685
        Button(action: action) {
686
            Text(title)
Bogdan Timofte authored 2 months ago
687
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
688
                .lineLimit(1)
689
                .minimumScaleFactor(0.82)
690
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 2 months ago
691
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
692
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
693
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored 2 months ago
694
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
695
                .background(
696
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
697
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
698
                )
699
                .overlay(
700
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
701
                        .stroke(Color.blue, lineWidth: 1.5)
702
                )
703
        }
704
        .buttonStyle(.plain)
705
    }
706

            
707
    private func symbolControlChip(
708
        systemImage: String,
709
        enabled: Bool,
710
        active: Bool,
711
        condensedLayout: Bool,
712
        showsLabel: Bool,
713
        label: String,
714
        accessibilityLabel: String,
715
        action: @escaping () -> Void
716
    ) -> some View {
717
        Button(action: {
718
            action()
719
        }) {
720
            Group {
721
                if showsLabel {
722
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 2 months ago
723
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 2 months ago
724
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
725
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored 2 months ago
726
                } else {
727
                    Image(systemName: systemImage)
Bogdan Timofte authored 2 months ago
728
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 2 months ago
729
                        .frame(
Bogdan Timofte authored 2 months ago
730
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
731
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 2 months ago
732
                        )
Bogdan Timofte authored 2 months ago
733
                }
734
            }
735
                .background(
736
                    Capsule(style: .continuous)
737
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
738
                )
739
        }
740
        .buttonStyle(.plain)
741
        .foregroundColor(enabled ? .primary : .secondary)
742
        .opacity(enabled ? 1 : 0.55)
743
        .accessibilityLabel(accessibilityLabel)
744
    }
745

            
Bogdan Timofte authored 2 months ago
746
    private func resetBuffer() {
747
        measurements.resetSeries()
Bogdan Timofte authored 2 months ago
748
    }
749

            
Bogdan Timofte authored 2 months ago
750
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
751
        if isLargeDisplay {
752
            return .body.weight(.semibold)
753
        }
754
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
755
    }
756

            
757
    private func controlChipFont(condensedLayout: Bool) -> Font {
758
        if isLargeDisplay {
759
            return .callout.weight(.semibold)
760
        }
761
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
762
    }
763

            
Bogdan Timofte authored 2 months ago
764
    @ViewBuilder
765
    private func primaryAxisView(
766
        height: CGFloat,
Bogdan Timofte authored 2 months ago
767
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
768
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
769
        voltageSeries: SeriesData,
770
        currentSeries: SeriesData
Bogdan Timofte authored 2 months ago
771
    ) -> some View {
772
        if displayPower {
773
            yAxisLabelsView(
774
                height: height,
775
                context: powerSeries.context,
Bogdan Timofte authored 2 months ago
776
                seriesKind: .power,
777
                measurementUnit: powerSeries.kind.unit,
778
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 months ago
779
            )
Bogdan Timofte authored 2 months ago
780
        } else if displayEnergy {
781
            yAxisLabelsView(
782
                height: height,
783
                context: energySeries.context,
784
                seriesKind: .energy,
785
                measurementUnit: energySeries.kind.unit,
786
                tint: energySeries.kind.tint
787
            )
Bogdan Timofte authored 2 months ago
788
        } else if displayVoltage {
789
            yAxisLabelsView(
790
                height: height,
791
                context: voltageSeries.context,
Bogdan Timofte authored 2 months ago
792
                seriesKind: .voltage,
793
                measurementUnit: voltageSeries.kind.unit,
794
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 months ago
795
            )
796
        } else if displayCurrent {
797
            yAxisLabelsView(
798
                height: height,
799
                context: currentSeries.context,
Bogdan Timofte authored 2 months ago
800
                seriesKind: .current,
801
                measurementUnit: currentSeries.kind.unit,
802
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 months ago
803
            )
804
        }
805
    }
806

            
807
    @ViewBuilder
808
    private func renderedChart(
Bogdan Timofte authored 2 months ago
809
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
810
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
811
        voltageSeries: SeriesData,
Bogdan Timofte authored 2 months ago
812
        currentSeries: SeriesData,
813
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 months ago
814
    ) -> some View {
815
        if self.displayPower {
816
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
817
                .opacity(0.72)
Bogdan Timofte authored 2 months ago
818
        } else if self.displayEnergy {
819
            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
820
                .opacity(0.78)
Bogdan Timofte authored 2 months ago
821
        } else {
822
            if self.displayVoltage {
823
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
824
                    .opacity(0.78)
825
            }
826
            if self.displayCurrent {
827
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
828
                    .opacity(0.78)
829
            }
830
        }
Bogdan Timofte authored 2 months ago
831

            
832
        if displayTemperature {
833
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
834
                .opacity(0.86)
835
        }
Bogdan Timofte authored 2 months ago
836
    }
837

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

            
874
    private func displayedPrimarySeries(
Bogdan Timofte authored 2 months ago
875
        powerSeries: SeriesData,
Bogdan Timofte authored 2 months ago
876
        energySeries: SeriesData,
Bogdan Timofte authored 2 months ago
877
        voltageSeries: SeriesData,
878
        currentSeries: SeriesData
879
    ) -> SeriesData? {
Bogdan Timofte authored 2 months ago
880
        if displayPower {
Bogdan Timofte authored 2 months ago
881
            return powerSeries
Bogdan Timofte authored 2 months ago
882
        }
Bogdan Timofte authored 2 months ago
883
        if displayEnergy {
884
            return energySeries
885
        }
Bogdan Timofte authored 2 months ago
886
        if displayVoltage {
Bogdan Timofte authored 2 months ago
887
            return voltageSeries
Bogdan Timofte authored 2 months ago
888
        }
889
        if displayCurrent {
Bogdan Timofte authored 2 months ago
890
            return currentSeries
Bogdan Timofte authored 2 months ago
891
        }
892
        return nil
893
    }
894

            
895
    private func series(
896
        for measurement: Measurements.Measurement,
Bogdan Timofte authored 2 months ago
897
        kind: SeriesKind,
Bogdan Timofte authored 2 months ago
898
        minimumYSpan: Double,
899
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
900
    ) -> SeriesData {
Bogdan Timofte authored 2 months ago
901
        let rawPoints = filteredPoints(
Bogdan Timofte authored 2 months ago
902
            measurement,
903
            visibleTimeRange: visibleTimeRange
904
        )
Bogdan Timofte authored a month ago
905
        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
906
        let points = smoothedPoints(from: normalizedRawPoints)
Bogdan Timofte authored 2 months ago
907
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 months ago
908
        let context = ChartContext()
Bogdan Timofte authored 2 months ago
909

            
910
        let autoBounds = automaticYBounds(
911
            for: samplePoints,
912
            minimumYSpan: minimumYSpan
913
        )
Bogdan Timofte authored 2 months ago
914
        let xBounds = xBounds(
915
            for: samplePoints,
916
            visibleTimeRange: visibleTimeRange
917
        )
Bogdan Timofte authored 2 months ago
918
        let lowerBound = resolvedLowerBound(
919
            for: kind,
920
            autoLowerBound: autoBounds.lowerBound
921
        )
922
        let upperBound = resolvedUpperBound(
923
            for: kind,
924
            lowerBound: lowerBound,
925
            autoUpperBound: autoBounds.upperBound,
926
            maximumSampleValue: samplePoints.map(\.value).max(),
927
            minimumYSpan: minimumYSpan
928
        )
929

            
930
        context.setBounds(
931
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
932
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
933
            yMin: CGFloat(lowerBound),
934
            yMax: CGFloat(upperBound)
935
        )
936

            
937
        return SeriesData(
938
            kind: kind,
939
            points: points,
940
            samplePoints: samplePoints,
941
            context: context,
942
            autoLowerBound: autoBounds.lowerBound,
943
            autoUpperBound: autoBounds.upperBound,
944
            maximumSampleValue: samplePoints.map(\.value).max()
945
        )
946
    }
947

            
Bogdan Timofte authored a month ago
948
    private func normalizedPoints(
949
        _ points: [Measurements.Measurement.Point],
950
        for kind: SeriesKind
951
    ) -> [Measurements.Measurement.Point] {
952
        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
953
            return points
954
        }
955

            
956
        guard let baseline = points.first(where: \.isSample)?.value else {
957
            return points
958
        }
959

            
960
        return points.enumerated().map { index, point in
961
            Measurements.Measurement.Point(
962
                id: point.id == index ? point.id : index,
963
                timestamp: point.timestamp,
964
                value: point.value - baseline,
965
                kind: point.kind
966
            )
967
        }
968
    }
969

            
Bogdan Timofte authored 2 months ago
970
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
971
        series(
972
            for: measurement(for: kind),
973
            kind: kind,
974
            minimumYSpan: minimumYSpan(for: kind)
975
        )
976
    }
977

            
Bogdan Timofte authored 2 months ago
978
    private func smoothedPoints(
979
        from points: [Measurements.Measurement.Point]
980
    ) -> [Measurements.Measurement.Point] {
981
        guard smoothingLevel != .off else { return points }
982

            
983
        var smoothedPoints: [Measurements.Measurement.Point] = []
984
        var currentSegment: [Measurements.Measurement.Point] = []
985

            
986
        func flushCurrentSegment() {
987
            guard !currentSegment.isEmpty else { return }
988

            
989
            for point in smoothedSegment(currentSegment) {
990
                smoothedPoints.append(
991
                    Measurements.Measurement.Point(
992
                        id: smoothedPoints.count,
993
                        timestamp: point.timestamp,
994
                        value: point.value,
995
                        kind: .sample
996
                    )
997
                )
998
            }
999

            
1000
            currentSegment.removeAll(keepingCapacity: true)
1001
        }
1002

            
1003
        for point in points {
1004
            if point.isDiscontinuity {
1005
                flushCurrentSegment()
1006

            
1007
                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
1008
                    smoothedPoints.append(
1009
                        Measurements.Measurement.Point(
1010
                            id: smoothedPoints.count,
1011
                            timestamp: point.timestamp,
1012
                            value: smoothedPoints.last?.value ?? point.value,
1013
                            kind: .discontinuity
1014
                        )
1015
                    )
1016
                }
1017
            } else {
1018
                currentSegment.append(point)
1019
            }
1020
        }
1021

            
1022
        flushCurrentSegment()
1023
        return smoothedPoints
1024
    }
1025

            
1026
    private func smoothedSegment(
1027
        _ segment: [Measurements.Measurement.Point]
1028
    ) -> [Measurements.Measurement.Point] {
1029
        let windowSize = smoothingLevel.movingAverageWindowSize
1030
        guard windowSize > 1, segment.count > 2 else { return segment }
1031

            
1032
        let radius = windowSize / 2
1033
        var prefixSums: [Double] = [0]
1034
        prefixSums.reserveCapacity(segment.count + 1)
1035

            
1036
        for point in segment {
1037
            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
1038
        }
1039

            
1040
        return segment.enumerated().map { index, point in
1041
            let lowerBound = max(0, index - radius)
1042
            let upperBound = min(segment.count - 1, index + radius)
1043
            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
1044
            let average = sum / Double(upperBound - lowerBound + 1)
1045

            
1046
            return Measurements.Measurement.Point(
1047
                id: point.id,
1048
                timestamp: point.timestamp,
1049
                value: average,
1050
                kind: .sample
1051
            )
1052
        }
1053
    }
1054

            
Bogdan Timofte authored 2 months ago
1055
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
1056
        switch kind {
1057
        case .power:
1058
            return measurements.power
Bogdan Timofte authored 2 months ago
1059
        case .energy:
1060
            return measurements.energy
Bogdan Timofte authored 2 months ago
1061
        case .voltage:
1062
            return measurements.voltage
1063
        case .current:
1064
            return measurements.current
1065
        case .temperature:
1066
            return measurements.temperature
1067
        }
1068
    }
1069

            
1070
    private func minimumYSpan(for kind: SeriesKind) -> Double {
1071
        switch kind {
1072
        case .power:
1073
            return minimumPowerSpan
Bogdan Timofte authored 2 months ago
1074
        case .energy:
1075
            return minimumEnergySpan
Bogdan Timofte authored 2 months ago
1076
        case .voltage:
1077
            return minimumVoltageSpan
1078
        case .current:
1079
            return minimumCurrentSpan
1080
        case .temperature:
1081
            return minimumTemperatureSpan
1082
        }
1083
    }
1084

            
Bogdan Timofte authored 2 months ago
1085
    private var supportsSharedOrigin: Bool {
Bogdan Timofte authored 2 months ago
1086
        displayVoltage && displayCurrent && !displayPower && !displayEnergy
Bogdan Timofte authored 2 months ago
1087
    }
1088

            
Bogdan Timofte authored 2 months ago
1089
    private var minimumSharedScaleSpan: Double {
1090
        max(minimumVoltageSpan, minimumCurrentSpan)
1091
    }
1092

            
Bogdan Timofte authored 2 months ago
1093
    private var pinnedOriginIsZero: Bool {
1094
        if useSharedOrigin && supportsSharedOrigin {
1095
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 months ago
1096
        }
Bogdan Timofte authored 2 months ago
1097

            
1098
        if displayPower {
1099
            return pinOrigin && powerAxisOrigin == 0
1100
        }
1101

            
Bogdan Timofte authored 2 months ago
1102
        if displayEnergy {
1103
            return pinOrigin && energyAxisOrigin == 0
1104
        }
1105

            
Bogdan Timofte authored 2 months ago
1106
        let visibleOrigins = [
1107
            displayVoltage ? voltageAxisOrigin : nil,
1108
            displayCurrent ? currentAxisOrigin : nil
1109
        ]
1110
        .compactMap { $0 }
1111

            
1112
        guard !visibleOrigins.isEmpty else { return false }
1113
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
1114
    }
1115

            
1116
    private func toggleSharedOrigin(
1117
        voltageSeries: SeriesData,
1118
        currentSeries: SeriesData
1119
    ) {
1120
        guard supportsSharedOrigin else { return }
1121

            
1122
        if useSharedOrigin {
1123
            useSharedOrigin = false
1124
            return
1125
        }
1126

            
1127
        captureCurrentOrigins(
1128
            voltageSeries: voltageSeries,
1129
            currentSeries: currentSeries
1130
        )
1131
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1132
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1133
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1134
        useSharedOrigin = true
1135
        pinOrigin = true
1136
    }
1137

            
1138
    private func togglePinnedOrigin(
1139
        voltageSeries: SeriesData,
1140
        currentSeries: SeriesData
1141
    ) {
1142
        if pinOrigin {
1143
            pinOrigin = false
1144
            return
1145
        }
1146

            
1147
        captureCurrentOrigins(
1148
            voltageSeries: voltageSeries,
1149
            currentSeries: currentSeries
1150
        )
1151
        pinOrigin = true
1152
    }
1153

            
1154
    private func setVisibleOriginsToZero() {
1155
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 2 months ago
1156
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1157
            sharedAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1158
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored 2 months ago
1159
            voltageAxisOrigin = 0
1160
            currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1161
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1162
        } else {
1163
            if displayPower {
1164
                powerAxisOrigin = 0
1165
            }
Bogdan Timofte authored 2 months ago
1166
            if displayEnergy {
1167
                energyAxisOrigin = 0
1168
            }
Bogdan Timofte authored 2 months ago
1169
            if displayVoltage {
1170
                voltageAxisOrigin = 0
1171
            }
1172
            if displayCurrent {
1173
                currentAxisOrigin = 0
1174
            }
Bogdan Timofte authored 2 months ago
1175
            if displayTemperature {
1176
                temperatureAxisOrigin = 0
1177
            }
Bogdan Timofte authored 2 months ago
1178
        }
1179

            
1180
        pinOrigin = true
1181
    }
1182

            
1183
    private func captureCurrentOrigins(
1184
        voltageSeries: SeriesData,
1185
        currentSeries: SeriesData
1186
    ) {
1187
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
Bogdan Timofte authored 2 months ago
1188
        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
Bogdan Timofte authored 2 months ago
1189
        voltageAxisOrigin = voltageSeries.autoLowerBound
1190
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 2 months ago
1191
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored 2 months ago
1192
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 2 months ago
1193
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
1194
        ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1195
    }
1196

            
1197
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1198
        let visibleTimeRange = activeVisibleTimeRange
1199

            
Bogdan Timofte authored 2 months ago
1200
        switch kind {
1201
        case .power:
Bogdan Timofte authored 2 months ago
1202
            return pinOrigin
1203
                ? powerAxisOrigin
1204
                : automaticYBounds(
1205
                    for: filteredSamplePoints(
1206
                        measurements.power,
1207
                        visibleTimeRange: visibleTimeRange
1208
                    ),
1209
                    minimumYSpan: minimumPowerSpan
1210
                ).lowerBound
Bogdan Timofte authored 2 months ago
1211
        case .energy:
1212
            return pinOrigin
1213
                ? energyAxisOrigin
1214
                : automaticYBounds(
1215
                    for: filteredSamplePoints(
1216
                        measurements.energy,
1217
                        visibleTimeRange: visibleTimeRange
1218
                    ),
1219
                    minimumYSpan: minimumEnergySpan
1220
                ).lowerBound
Bogdan Timofte authored 2 months ago
1221
        case .voltage:
1222
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1223
                return sharedAxisOrigin
1224
            }
Bogdan Timofte authored 2 months ago
1225
            return pinOrigin
1226
                ? voltageAxisOrigin
1227
                : automaticYBounds(
1228
                    for: filteredSamplePoints(
1229
                        measurements.voltage,
1230
                        visibleTimeRange: visibleTimeRange
1231
                    ),
1232
                    minimumYSpan: minimumVoltageSpan
1233
                ).lowerBound
Bogdan Timofte authored 2 months ago
1234
        case .current:
1235
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
1236
                return sharedAxisOrigin
1237
            }
Bogdan Timofte authored 2 months ago
1238
            return pinOrigin
1239
                ? currentAxisOrigin
1240
                : automaticYBounds(
1241
                    for: filteredSamplePoints(
1242
                        measurements.current,
1243
                        visibleTimeRange: visibleTimeRange
1244
                    ),
1245
                    minimumYSpan: minimumCurrentSpan
1246
                ).lowerBound
Bogdan Timofte authored 2 months ago
1247
        case .temperature:
Bogdan Timofte authored 2 months ago
1248
            return pinOrigin
1249
                ? temperatureAxisOrigin
1250
                : automaticYBounds(
1251
                    for: filteredSamplePoints(
1252
                        measurements.temperature,
1253
                        visibleTimeRange: visibleTimeRange
1254
                    ),
1255
                    minimumYSpan: minimumTemperatureSpan
1256
                ).lowerBound
Bogdan Timofte authored 2 months ago
1257
        }
1258
    }
1259

            
Bogdan Timofte authored 2 months ago
1260
    private var activeVisibleTimeRange: ClosedRange<Date>? {
1261
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
1262
    }
1263

            
1264
    private func filteredPoints(
1265
        _ measurement: Measurements.Measurement,
1266
        visibleTimeRange: ClosedRange<Date>? = nil
1267
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored 2 months ago
1268
        let resolvedRange: ClosedRange<Date>?
1269

            
1270
        switch (timeRange, visibleTimeRange) {
1271
        case let (baseRange?, visibleRange?):
1272
            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1273
            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1274
            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1275
        case let (baseRange?, nil):
1276
            resolvedRange = baseRange
1277
        case let (nil, visibleRange?):
1278
            resolvedRange = visibleRange
1279
        case (nil, nil):
1280
            resolvedRange = nil
Bogdan Timofte authored 2 months ago
1281
        }
Bogdan Timofte authored 2 months ago
1282

            
1283
        guard let resolvedRange else {
1284
            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1285
        }
1286

            
1287
        return measurement.points(in: resolvedRange)
Bogdan Timofte authored 2 months ago
1288
    }
1289

            
1290
    private func filteredSamplePoints(
1291
        _ measurement: Measurements.Measurement,
1292
        visibleTimeRange: ClosedRange<Date>? = nil
1293
    ) -> [Measurements.Measurement.Point] {
1294
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1295
            point.isSample
Bogdan Timofte authored 2 months ago
1296
        }
1297
    }
1298

            
1299
    private func xBounds(
Bogdan Timofte authored 2 months ago
1300
        for samplePoints: [Measurements.Measurement.Point],
1301
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 months ago
1302
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 2 months ago
1303
        if let visibleTimeRange {
1304
            return normalizedTimeRange(visibleTimeRange)
1305
        }
1306

            
Bogdan Timofte authored 2 months ago
1307
        if let timeRange {
Bogdan Timofte authored 2 months ago
1308
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored 2 months ago
1309
        }
1310

            
1311
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1312
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1313

            
Bogdan Timofte authored 2 months ago
1314
        return normalizedTimeRange(lowerBound...upperBound)
1315
    }
1316

            
1317
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1318
        if let timeRange {
1319
            return normalizedTimeRange(timeRange)
1320
        }
1321

            
1322
        let samplePoints = timelineSamplePoints()
1323
        guard let lowerBound = samplePoints.first?.timestamp else {
1324
            return nil
1325
        }
1326

            
1327
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1328
        return normalizedTimeRange(lowerBound...upperBound)
1329
    }
1330

            
1331
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1332
        let candidates = [
1333
            filteredSamplePoints(measurements.power),
Bogdan Timofte authored 2 months ago
1334
            filteredSamplePoints(measurements.energy),
Bogdan Timofte authored 2 months ago
1335
            filteredSamplePoints(measurements.voltage),
1336
            filteredSamplePoints(measurements.current),
1337
            filteredSamplePoints(measurements.temperature)
1338
        ]
1339

            
1340
        return candidates.first(where: { !$0.isEmpty }) ?? []
1341
    }
1342

            
1343
    private func resolvedVisibleTimeRange(
1344
        within availableTimeRange: ClosedRange<Date>?
1345
    ) -> ClosedRange<Date>? {
1346
        guard let availableTimeRange else { return nil }
1347
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1348

            
1349
        if isPinnedToPresent {
1350
            let pinnedRange: ClosedRange<Date>
1351

            
1352
            switch presentTrackingMode {
1353
            case .keepDuration:
1354
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1355
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1356
            case .keepStartTimestamp:
1357
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1358
            }
1359

            
1360
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1361
        }
1362

            
1363
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1364
    }
1365

            
1366
    private func clampedTimeRange(
1367
        _ candidateRange: ClosedRange<Date>,
1368
        within bounds: ClosedRange<Date>
1369
    ) -> ClosedRange<Date> {
1370
        let normalizedBounds = normalizedTimeRange(bounds)
1371
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1372

            
1373
        guard boundsSpan > 0 else {
1374
            return normalizedBounds
1375
        }
1376

            
1377
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1378
        let requestedSpan = min(
1379
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1380
            boundsSpan
1381
        )
1382

            
1383
        if requestedSpan >= boundsSpan {
1384
            return normalizedBounds
1385
        }
1386

            
1387
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1388
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1389

            
1390
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1391
            if lowerBound == normalizedBounds.lowerBound {
1392
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1393
            } else {
1394
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1395
            }
1396
        }
1397

            
1398
        if upperBound > normalizedBounds.upperBound {
1399
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1400
            upperBound = normalizedBounds.upperBound
1401
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored 2 months ago
1402
        }
1403

            
Bogdan Timofte authored 2 months ago
1404
        if lowerBound < normalizedBounds.lowerBound {
1405
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1406
            lowerBound = normalizedBounds.lowerBound
1407
            upperBound = upperBound.addingTimeInterval(delta)
1408
        }
1409

            
1410
        return lowerBound...upperBound
1411
    }
1412

            
1413
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1414
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1415
        guard span < minimumTimeSpan else { return range }
1416

            
1417
        let expansion = (minimumTimeSpan - span) / 2
1418
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1419
    }
1420

            
1421
    private func shouldShowRangeSelector(
1422
        availableTimeRange: ClosedRange<Date>,
1423
        series: SeriesData
1424
    ) -> Bool {
1425
        series.samplePoints.count > 1 &&
1426
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored 2 months ago
1427
    }
1428

            
1429
    private func automaticYBounds(
1430
        for samplePoints: [Measurements.Measurement.Point],
1431
        minimumYSpan: Double
1432
    ) -> (lowerBound: Double, upperBound: Double) {
1433
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1434

            
1435
        guard
1436
            let minimumSampleValue = samplePoints.map(\.value).min(),
1437
            let maximumSampleValue = samplePoints.map(\.value).max()
1438
        else {
1439
            return (0, minimumYSpan)
Bogdan Timofte authored 2 months ago
1440
        }
Bogdan Timofte authored 2 months ago
1441

            
1442
        var lowerBound = minimumSampleValue
1443
        var upperBound = maximumSampleValue
1444
        let currentSpan = upperBound - lowerBound
1445

            
1446
        if currentSpan < minimumYSpan {
1447
            let expansion = (minimumYSpan - currentSpan) / 2
1448
            lowerBound -= expansion
1449
            upperBound += expansion
1450
        }
1451

            
1452
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1453
            let shift = -negativeAllowance - lowerBound
1454
            lowerBound += shift
1455
            upperBound += shift
1456
        }
1457

            
1458
        let snappedLowerBound = snappedOriginValue(lowerBound)
1459
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1460
        return (snappedLowerBound, resolvedUpperBound)
1461
    }
1462

            
1463
    private func resolvedLowerBound(
1464
        for kind: SeriesKind,
1465
        autoLowerBound: Double
1466
    ) -> Double {
1467
        guard pinOrigin else { return autoLowerBound }
1468

            
1469
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1470
            return sharedAxisOrigin
1471
        }
1472

            
1473
        switch kind {
1474
        case .power:
1475
            return powerAxisOrigin
Bogdan Timofte authored 2 months ago
1476
        case .energy:
1477
            return energyAxisOrigin
Bogdan Timofte authored 2 months ago
1478
        case .voltage:
1479
            return voltageAxisOrigin
1480
        case .current:
1481
            return currentAxisOrigin
Bogdan Timofte authored 2 months ago
1482
        case .temperature:
1483
            return temperatureAxisOrigin
Bogdan Timofte authored 2 months ago
1484
        }
1485
    }
1486

            
1487
    private func resolvedUpperBound(
1488
        for kind: SeriesKind,
1489
        lowerBound: Double,
1490
        autoUpperBound: Double,
1491
        maximumSampleValue: Double?,
1492
        minimumYSpan: Double
1493
    ) -> Double {
1494
        guard pinOrigin else {
1495
            return autoUpperBound
1496
        }
1497

            
Bogdan Timofte authored 2 months ago
1498
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1499
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1500
        }
1501

            
Bogdan Timofte authored 2 months ago
1502
        if kind == .temperature {
1503
            return autoUpperBound
1504
        }
1505

            
Bogdan Timofte authored 2 months ago
1506
        return max(
1507
            maximumSampleValue ?? lowerBound,
1508
            lowerBound + minimumYSpan,
1509
            autoUpperBound
1510
        )
1511
    }
1512

            
Bogdan Timofte authored 2 months ago
1513
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored 2 months ago
1514
        let baseline = displayedLowerBoundForSeries(kind)
1515
        let proposedOrigin = snappedOriginValue(baseline + delta)
1516

            
1517
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 2 months ago
1518
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored 2 months ago
1519
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 2 months ago
1520
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1521
            ensureSharedScaleSpan()
Bogdan Timofte authored 2 months ago
1522
        } else {
1523
            switch kind {
1524
            case .power:
1525
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
Bogdan Timofte authored 2 months ago
1526
            case .energy:
1527
                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
Bogdan Timofte authored 2 months ago
1528
            case .voltage:
1529
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1530
            case .current:
1531
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 2 months ago
1532
            case .temperature:
1533
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored 2 months ago
1534
            }
1535
        }
1536

            
1537
        pinOrigin = true
1538
    }
1539

            
Bogdan Timofte authored 2 months ago
1540
    private func clearOriginOffset(for kind: SeriesKind) {
1541
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1542
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1543
            sharedAxisOrigin = 0
1544
            sharedAxisUpperBound = currentSpan
1545
            ensureSharedScaleSpan()
1546
            voltageAxisOrigin = 0
1547
            currentAxisOrigin = 0
1548
        } else {
1549
            switch kind {
1550
            case .power:
1551
                powerAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1552
            case .energy:
1553
                energyAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1554
            case .voltage:
1555
                voltageAxisOrigin = 0
1556
            case .current:
1557
                currentAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1558
            case .temperature:
1559
                temperatureAxisOrigin = 0
Bogdan Timofte authored 2 months ago
1560
            }
1561
        }
1562

            
1563
        pinOrigin = true
1564
    }
1565

            
1566
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1567
        guard totalHeight > 1 else { return }
1568

            
1569
        let normalized = max(0, min(1, locationY / totalHeight))
1570
        if normalized < (1.0 / 3.0) {
1571
            applyOriginDelta(-1, kind: kind)
1572
        } else if normalized < (2.0 / 3.0) {
1573
            clearOriginOffset(for: kind)
1574
        } else {
1575
            applyOriginDelta(1, kind: kind)
1576
        }
1577
    }
1578

            
Bogdan Timofte authored 2 months ago
1579
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 2 months ago
1580
        let visibleTimeRange = activeVisibleTimeRange
1581

            
Bogdan Timofte authored 2 months ago
1582
        switch kind {
1583
        case .power:
Bogdan Timofte authored 2 months ago
1584
            return snappedOriginValue(
1585
                filteredSamplePoints(
1586
                    measurements.power,
1587
                    visibleTimeRange: visibleTimeRange
1588
                ).map(\.value).min() ?? 0
1589
            )
Bogdan Timofte authored 2 months ago
1590
        case .energy:
1591
            return snappedOriginValue(
1592
                filteredSamplePoints(
1593
                    measurements.energy,
1594
                    visibleTimeRange: visibleTimeRange
1595
                ).map(\.value).min() ?? 0
1596
            )
Bogdan Timofte authored 2 months ago
1597
        case .voltage:
Bogdan Timofte authored 2 months ago
1598
            return snappedOriginValue(
1599
                filteredSamplePoints(
1600
                    measurements.voltage,
1601
                    visibleTimeRange: visibleTimeRange
1602
                ).map(\.value).min() ?? 0
1603
            )
Bogdan Timofte authored 2 months ago
1604
        case .current:
Bogdan Timofte authored 2 months ago
1605
            return snappedOriginValue(
1606
                filteredSamplePoints(
1607
                    measurements.current,
1608
                    visibleTimeRange: visibleTimeRange
1609
                ).map(\.value).min() ?? 0
1610
            )
Bogdan Timofte authored 2 months ago
1611
        case .temperature:
Bogdan Timofte authored 2 months ago
1612
            return snappedOriginValue(
1613
                filteredSamplePoints(
1614
                    measurements.temperature,
1615
                    visibleTimeRange: visibleTimeRange
1616
                ).map(\.value).min() ?? 0
1617
            )
Bogdan Timofte authored 2 months ago
1618
        }
1619
    }
1620

            
1621
    private func maximumVisibleSharedOrigin() -> Double {
1622
        min(
1623
            maximumVisibleOrigin(for: .voltage),
1624
            maximumVisibleOrigin(for: .current)
1625
        )
1626
    }
1627

            
Bogdan Timofte authored 2 months ago
1628
    private func measurementUnit(for kind: SeriesKind) -> String {
1629
        switch kind {
1630
        case .temperature:
1631
            let locale = Locale.autoupdatingCurrent
1632
            if #available(iOS 16.0, *) {
1633
                switch locale.measurementSystem {
1634
                case .us:
1635
                    return "°F"
1636
                default:
1637
                    return "°C"
1638
                }
1639
            }
1640

            
1641
            let regionCode = locale.regionCode ?? ""
1642
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1643
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1644
        default:
1645
            return kind.unit
1646
        }
1647
    }
1648

            
Bogdan Timofte authored 2 months ago
1649
    private func ensureSharedScaleSpan() {
1650
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1651
    }
1652

            
Bogdan Timofte authored 2 months ago
1653
    private func snappedOriginValue(_ value: Double) -> Double {
1654
        if value >= 0 {
1655
            return value.rounded(.down)
1656
        }
1657

            
1658
        return value.rounded(.up)
Bogdan Timofte authored 2 months ago
1659
    }
Bogdan Timofte authored 2 months ago
1660

            
Bogdan Timofte authored 2 months ago
1661
    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1662
        measurements.keepOnly(in: range)
1663
        selectedVisibleTimeRange = nil
1664
        isPinnedToPresent = false
1665
    }
1666

            
1667
    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1668
        measurements.removeValues(in: range)
1669
        selectedVisibleTimeRange = nil
1670
        isPinnedToPresent = false
1671
    }
1672

            
Bogdan Timofte authored 2 months ago
1673
    private func yGuidePosition(
1674
        for labelIndex: Int,
1675
        context: ChartContext,
1676
        height: CGFloat
1677
    ) -> CGFloat {
1678
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1679
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1680
        return context.placeInRect(point: anchorPoint).y * height
1681
    }
1682

            
1683
    private func xGuidePosition(
1684
        for labelIndex: Int,
1685
        context: ChartContext,
1686
        width: CGFloat
1687
    ) -> CGFloat {
1688
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1689
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1690
        return context.placeInRect(point: anchorPoint).x * width
1691
    }
Bogdan Timofte authored 2 months ago
1692

            
1693
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 months ago
1694
    fileprivate func xAxisLabelsView(
1695
        context: ChartContext
1696
    ) -> some View {
Bogdan Timofte authored 2 months ago
1697
        var timeFormat: String?
1698
        switch context.size.width {
1699
        case 0..<3600: timeFormat = "HH:mm:ss"
1700
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 months ago
1701
        default: timeFormat = "E HH:mm"
1702
        }
1703
        let labels = (1...xLabels).map {
1704
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 months ago
1705
        }
Bogdan Timofte authored 2 months ago
1706
        let axisLabelFont: Font = {
1707
            if isIPhone && isPortraitLayout {
1708
                return .caption2.weight(.semibold)
1709
            }
1710
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1711
        }()
Bogdan Timofte authored 2 months ago
1712

            
1713
        return HStack(spacing: chartSectionSpacing) {
1714
            Color.clear
1715
                .frame(width: axisColumnWidth)
1716

            
1717
            GeometryReader { geometry in
1718
                let labelWidth = max(
1719
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1720
                    1
1721
                )
1722

            
1723
                ZStack(alignment: .topLeading) {
1724
                    Path { path in
1725
                        for labelIndex in 1...self.xLabels {
1726
                            let x = xGuidePosition(
1727
                                for: labelIndex,
1728
                                context: context,
1729
                                width: geometry.size.width
1730
                            )
1731
                            path.move(to: CGPoint(x: x, y: 0))
1732
                            path.addLine(to: CGPoint(x: x, y: 6))
1733
                        }
1734
                    }
1735
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1736

            
1737
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1738
                        let labelIndex = item.offset + 1
1739
                        let centerX = xGuidePosition(
1740
                            for: labelIndex,
1741
                            context: context,
1742
                            width: geometry.size.width
1743
                        )
1744

            
1745
                        Text(item.element)
Bogdan Timofte authored 2 months ago
1746
                            .font(axisLabelFont)
Bogdan Timofte authored 2 months ago
1747
                            .monospacedDigit()
1748
                            .lineLimit(1)
Bogdan Timofte authored 2 months ago
1749
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 months ago
1750
                            .frame(width: labelWidth)
1751
                            .position(
1752
                                x: centerX,
1753
                                y: geometry.size.height * 0.7
1754
                            )
Bogdan Timofte authored 2 months ago
1755
                    }
1756
                }
Bogdan Timofte authored 2 months ago
1757
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 months ago
1758
            }
Bogdan Timofte authored 2 months ago
1759

            
1760
            Color.clear
1761
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 months ago
1762
        }
1763
    }
1764

            
Bogdan Timofte authored 2 months ago
1765
    private func yAxisLabelsView(
Bogdan Timofte authored 2 months ago
1766
        height: CGFloat,
1767
        context: ChartContext,
Bogdan Timofte authored 2 months ago
1768
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 months ago
1769
        measurementUnit: String,
1770
        tint: Color
1771
    ) -> some View {
Bogdan Timofte authored 2 months ago
1772
        let yAxisFont: Font = {
1773
            if isIPhone && isPortraitLayout {
1774
                return .caption2.weight(.semibold)
1775
            }
1776
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1777
        }()
1778

            
1779
        let unitFont: Font = {
1780
            if isIPhone && isPortraitLayout {
1781
                return .caption2.weight(.bold)
1782
            }
1783
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1784
        }()
1785

            
1786
        return GeometryReader { geometry in
Bogdan Timofte authored 2 months ago
1787
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1788
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1789
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1790

            
Bogdan Timofte authored 2 months ago
1791
            ZStack(alignment: .top) {
1792
                ForEach(0..<yLabels, id: \.self) { row in
1793
                    let labelIndex = yLabels - row
1794

            
1795
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 2 months ago
1796
                        .font(yAxisFont)
Bogdan Timofte authored 2 months ago
1797
                        .monospacedDigit()
1798
                        .lineLimit(1)
Bogdan Timofte authored 2 months ago
1799
                        .minimumScaleFactor(0.8)
1800
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 months ago
1801
                        .position(
1802
                            x: geometry.size.width / 2,
Bogdan Timofte authored 2 months ago
1803
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 months ago
1804
                                for: labelIndex,
1805
                                context: context,
Bogdan Timofte authored 2 months ago
1806
                                height: labelAreaHeight
Bogdan Timofte authored 2 months ago
1807
                            )
1808
                        )
Bogdan Timofte authored 2 months ago
1809
                }
Bogdan Timofte authored 2 months ago
1810

            
Bogdan Timofte authored 2 months ago
1811
                Text(measurementUnit)
Bogdan Timofte authored 2 months ago
1812
                    .font(unitFont)
Bogdan Timofte authored 2 months ago
1813
                    .foregroundColor(tint)
Bogdan Timofte authored 2 months ago
1814
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1815
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 months ago
1816
                    .background(
1817
                        Capsule(style: .continuous)
1818
                            .fill(tint.opacity(0.14))
1819
                    )
Bogdan Timofte authored 2 months ago
1820
                    .padding(.top, 8)
1821

            
Bogdan Timofte authored 2 months ago
1822
            }
1823
        }
Bogdan Timofte authored 2 months ago
1824
        .frame(height: height)
1825
        .background(
1826
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1827
                .fill(tint.opacity(0.12))
1828
        )
1829
        .overlay(
1830
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1831
                .stroke(tint.opacity(0.20), lineWidth: 1)
1832
        )
Bogdan Timofte authored 2 months ago
1833
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1834
        .gesture(
Bogdan Timofte authored 2 months ago
1835
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored 2 months ago
1836
                .onEnded { value in
Bogdan Timofte authored 2 months ago
1837
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored 2 months ago
1838
                }
1839
        )
Bogdan Timofte authored 2 months ago
1840
    }
1841

            
Bogdan Timofte authored 2 months ago
1842
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 months ago
1843
        GeometryReader { geometry in
1844
            Path { path in
Bogdan Timofte authored 2 months ago
1845
                for labelIndex in 1...self.yLabels {
1846
                    let y = yGuidePosition(
1847
                        for: labelIndex,
1848
                        context: context,
1849
                        height: geometry.size.height
1850
                    )
1851
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 months ago
1852
                }
Bogdan Timofte authored 2 months ago
1853
            }
1854
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 months ago
1855
        }
1856
    }
1857

            
Bogdan Timofte authored 2 months ago
1858
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 months ago
1859
        GeometryReader { geometry in
1860
            Path { path in
1861

            
Bogdan Timofte authored 2 months ago
1862
                for labelIndex in 2..<self.xLabels {
1863
                    let x = xGuidePosition(
1864
                        for: labelIndex,
1865
                        context: context,
1866
                        width: geometry.size.width
1867
                    )
1868
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 months ago
1869
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1870
                }
Bogdan Timofte authored 2 months ago
1871
            }
1872
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 months ago
1873
        }
1874
    }
Bogdan Timofte authored 2 months ago
1875

            
1876
    fileprivate func discontinuityMarkers(
1877
        points: [Measurements.Measurement.Point],
1878
        context: ChartContext
1879
    ) -> some View {
1880
        GeometryReader { geometry in
1881
            Path { path in
1882
                for point in points where point.isDiscontinuity {
1883
                    let markerX = context.placeInRect(
1884
                        point: CGPoint(
1885
                            x: point.timestamp.timeIntervalSince1970,
1886
                            y: context.origin.y
1887
                        )
1888
                    ).x * geometry.size.width
1889
                    path.move(to: CGPoint(x: markerX, y: 0))
1890
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1891
                }
1892
            }
1893
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1894
        }
1895
    }
Bogdan Timofte authored 2 months ago
1896

            
1897
}
1898

            
Bogdan Timofte authored 2 months ago
1899
private struct TimeRangeSelectorView: View {
1900
    private enum DragTarget {
1901
        case lowerBound
1902
        case upperBound
1903
        case window
1904
    }
1905

            
Bogdan Timofte authored 2 months ago
1906
    private enum ActionTone {
1907
        case reversible
1908
        case destructive
1909
        case destructiveProminent
1910
    }
1911

            
Bogdan Timofte authored 2 months ago
1912
    private struct DragState {
1913
        let target: DragTarget
1914
        let initialRange: ClosedRange<Date>
1915
    }
1916

            
1917
    let points: [Measurements.Measurement.Point]
1918
    let context: ChartContext
1919
    let availableTimeRange: ClosedRange<Date>
Bogdan Timofte authored 2 months ago
1920
    let selectorTint: Color
Bogdan Timofte authored 2 months ago
1921
    let compactLayout: Bool
1922
    let minimumSelectionSpan: TimeInterval
Bogdan Timofte authored 2 months ago
1923
    let onKeepSelection: (ClosedRange<Date>) -> Void
1924
    let onRemoveSelection: (ClosedRange<Date>) -> Void
1925
    let onResetBuffer: () -> Void
Bogdan Timofte authored 2 months ago
1926

            
1927
    @Binding var selectedTimeRange: ClosedRange<Date>?
1928
    @Binding var isPinnedToPresent: Bool
1929
    @Binding var presentTrackingMode: PresentTrackingMode
1930
    @State private var dragState: DragState?
Bogdan Timofte authored 2 months ago
1931
    @State private var showResetConfirmation: Bool = false
Bogdan Timofte authored 2 months ago
1932

            
1933
    private var totalSpan: TimeInterval {
1934
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
1935
    }
1936

            
1937
    private var currentRange: ClosedRange<Date> {
1938
        resolvedSelectionRange()
1939
    }
1940

            
1941
    private var trackHeight: CGFloat {
1942
        compactLayout ? 72 : 86
1943
    }
1944

            
1945
    private var cornerRadius: CGFloat {
1946
        compactLayout ? 14 : 16
1947
    }
1948

            
1949
    private var boundaryFont: Font {
1950
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1951
    }
1952

            
1953
    private var symbolButtonSize: CGFloat {
1954
        compactLayout ? 28 : 32
1955
    }
1956

            
1957
    var body: some View {
1958
        let coversFullRange = selectionCoversFullRange(currentRange)
1959

            
1960
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
1961
            if !coversFullRange || isPinnedToPresent {
1962
                HStack(spacing: 8) {
1963
                    alignmentButton(
1964
                        systemName: "arrow.left.to.line.compact",
1965
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
1966
                        action: alignSelectionToLeadingEdge,
1967
                        accessibilityLabel: "Align selection to start"
1968
                    )
1969

            
1970
                    alignmentButton(
1971
                        systemName: "arrow.right.to.line.compact",
1972
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
1973
                        action: alignSelectionToTrailingEdge,
1974
                        accessibilityLabel: "Align selection to present"
1975
                    )
1976

            
1977
                    Spacer(minLength: 0)
1978

            
1979
                    if isPinnedToPresent {
1980
                        trackingModeToggleButton()
1981
                    }
1982
                }
1983
            }
1984

            
Bogdan Timofte authored 2 months ago
1985
            HStack(spacing: 8) {
1986
                if !coversFullRange {
1987
                    actionButton(
1988
                        title: compactLayout ? "Keep" : "Keep Selection",
1989
                        systemName: "scissors",
1990
                        tone: .destructive,
1991
                        action: {
1992
                            onKeepSelection(currentRange)
1993
                        }
1994
                    )
1995

            
1996
                    actionButton(
1997
                        title: compactLayout ? "Cut" : "Remove Selection",
1998
                        systemName: "minus.circle",
1999
                        tone: .destructive,
2000
                        action: {
2001
                            onRemoveSelection(currentRange)
2002
                        }
2003
                    )
2004
                }
2005

            
2006
                Spacer(minLength: 0)
2007

            
2008
                actionButton(
2009
                    title: compactLayout ? "Reset" : "Reset Buffer",
2010
                    systemName: "trash",
2011
                    tone: .destructiveProminent,
2012
                    action: {
2013
                        showResetConfirmation = true
2014
                    }
2015
                )
2016
            }
2017
            .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
2018
                Button("Reset buffer", role: .destructive) {
2019
                    onResetBuffer()
2020
                }
2021
                Button("Cancel", role: .cancel) {}
2022
            }
2023

            
Bogdan Timofte authored 2 months ago
2024
            GeometryReader { geometry in
2025
                let selectionFrame = selectionFrame(in: geometry.size)
2026
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
2027

            
2028
                ZStack(alignment: .topLeading) {
2029
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2030
                        .fill(Color.primary.opacity(0.05))
2031

            
2032
                    Chart(
2033
                        points: points,
2034
                        context: context,
2035
                        areaChart: true,
Bogdan Timofte authored 2 months ago
2036
                        strokeColor: selectorTint,
2037
                        areaFillColor: selectorTint.opacity(0.22)
Bogdan Timofte authored 2 months ago
2038
                    )
2039
                    .opacity(0.94)
2040
                    .allowsHitTesting(false)
2041

            
2042
                    Chart(
2043
                        points: points,
2044
                        context: context,
Bogdan Timofte authored 2 months ago
2045
                        strokeColor: selectorTint.opacity(0.56)
Bogdan Timofte authored 2 months ago
2046
                    )
2047
                    .opacity(0.82)
2048
                    .allowsHitTesting(false)
2049

            
2050
                    if selectionFrame.minX > 0 {
2051
                        Rectangle()
2052
                            .fill(dimmingColor)
2053
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
2054
                            .allowsHitTesting(false)
2055
                    }
2056

            
2057
                    if selectionFrame.maxX < geometry.size.width {
2058
                        Rectangle()
2059
                            .fill(dimmingColor)
2060
                            .frame(
2061
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
2062
                                height: geometry.size.height
2063
                            )
2064
                            .offset(x: selectionFrame.maxX)
2065
                            .allowsHitTesting(false)
2066
                    }
2067

            
2068
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2069
                        .fill(selectorTint.opacity(0.18))
Bogdan Timofte authored 2 months ago
2070
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2071
                        .offset(x: selectionFrame.minX)
2072
                        .allowsHitTesting(false)
2073

            
2074
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
Bogdan Timofte authored 2 months ago
2075
                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
Bogdan Timofte authored 2 months ago
2076
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
2077
                        .offset(x: selectionFrame.minX)
2078
                        .allowsHitTesting(false)
2079

            
2080
                    handleView(height: max(geometry.size.height - 18, 16))
2081
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
2082
                        .allowsHitTesting(false)
2083

            
2084
                    handleView(height: max(geometry.size.height - 18, 16))
2085
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
2086
                        .allowsHitTesting(false)
2087
                }
2088
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
2089
                .overlay(
2090
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2091
                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
2092
                )
2093
                .contentShape(Rectangle())
2094
                .gesture(selectionGesture(totalWidth: geometry.size.width))
2095
            }
2096
            .frame(height: trackHeight)
2097

            
2098
            HStack {
2099
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
2100
                Spacer(minLength: 0)
2101
                Text(boundaryLabel(for: availableTimeRange.upperBound))
2102
            }
2103
            .font(boundaryFont)
2104
            .foregroundColor(.secondary)
2105
            .monospacedDigit()
2106
        }
2107
    }
2108

            
2109
    private func handleView(height: CGFloat) -> some View {
2110
        Capsule(style: .continuous)
2111
            .fill(Color.white.opacity(0.95))
2112
            .frame(width: 6, height: height)
2113
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
2114
    }
2115

            
2116
    private func alignmentButton(
2117
        systemName: String,
2118
        isActive: Bool,
2119
        action: @escaping () -> Void,
2120
        accessibilityLabel: String
2121
    ) -> some View {
2122
        Button(action: action) {
2123
            Image(systemName: systemName)
2124
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2125
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2126
        }
2127
        .buttonStyle(.plain)
Bogdan Timofte authored 2 months ago
2128
        .foregroundColor(isActive ? .white : selectorTint)
Bogdan Timofte authored 2 months ago
2129
        .background(
2130
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2131
                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
Bogdan Timofte authored 2 months ago
2132
        )
2133
        .overlay(
2134
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2135
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2136
        )
2137
        .accessibilityLabel(accessibilityLabel)
2138
    }
2139

            
2140
    private func trackingModeToggleButton() -> some View {
2141
        Button {
2142
            presentTrackingMode = presentTrackingMode == .keepDuration
2143
                ? .keepStartTimestamp
2144
                : .keepDuration
2145
        } label: {
2146
            Image(systemName: trackingModeSymbolName)
2147
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
2148
                .frame(width: symbolButtonSize, height: symbolButtonSize)
2149
        }
2150
        .buttonStyle(.plain)
2151
        .foregroundColor(.white)
2152
        .background(
2153
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2154
                .fill(selectorTint)
Bogdan Timofte authored 2 months ago
2155
        )
2156
        .overlay(
2157
            RoundedRectangle(cornerRadius: 9, style: .continuous)
Bogdan Timofte authored 2 months ago
2158
                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
Bogdan Timofte authored 2 months ago
2159
        )
2160
        .accessibilityLabel(trackingModeAccessibilityLabel)
2161
        .accessibilityHint("Toggles how the interval follows the present")
2162
    }
2163

            
Bogdan Timofte authored 2 months ago
2164
    private func actionButton(
2165
        title: String,
2166
        systemName: String,
2167
        tone: ActionTone,
2168
        action: @escaping () -> Void
2169
    ) -> some View {
2170
        let foregroundColor: Color = {
2171
            switch tone {
2172
            case .reversible, .destructive:
2173
                return toneColor(for: tone)
2174
            case .destructiveProminent:
2175
                return .white
2176
            }
2177
        }()
2178

            
2179
        return Button(action: action) {
2180
            Label(title, systemImage: systemName)
2181
                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2182
                .padding(.horizontal, compactLayout ? 10 : 12)
2183
                .padding(.vertical, compactLayout ? 7 : 8)
2184
        }
2185
        .buttonStyle(.plain)
2186
        .foregroundColor(foregroundColor)
2187
        .background(
2188
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2189
                .fill(actionButtonBackground(for: tone))
2190
        )
2191
        .overlay(
2192
            RoundedRectangle(cornerRadius: 10, style: .continuous)
2193
                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2194
        )
2195
    }
2196

            
2197
    private func toneColor(for tone: ActionTone) -> Color {
2198
        switch tone {
2199
        case .reversible:
2200
            return selectorTint
2201
        case .destructive, .destructiveProminent:
2202
            return .red
2203
        }
2204
    }
2205

            
2206
    private func actionButtonBackground(for tone: ActionTone) -> Color {
2207
        switch tone {
2208
        case .reversible:
2209
            return selectorTint.opacity(0.12)
2210
        case .destructive:
2211
            return Color.red.opacity(0.12)
2212
        case .destructiveProminent:
2213
            return Color.red.opacity(0.82)
2214
        }
2215
    }
2216

            
2217
    private func actionButtonBorder(for tone: ActionTone) -> Color {
2218
        switch tone {
2219
        case .reversible:
2220
            return selectorTint.opacity(0.22)
2221
        case .destructive:
2222
            return Color.red.opacity(0.22)
2223
        case .destructiveProminent:
2224
            return Color.red.opacity(0.72)
2225
        }
2226
    }
2227

            
Bogdan Timofte authored 2 months ago
2228
    private var trackingModeSymbolName: String {
2229
        switch presentTrackingMode {
2230
        case .keepDuration:
2231
            return "arrow.left.and.right"
2232
        case .keepStartTimestamp:
2233
            return "arrow.left.to.line.compact"
2234
        }
2235
    }
2236

            
2237
    private var trackingModeAccessibilityLabel: String {
2238
        switch presentTrackingMode {
2239
        case .keepDuration:
2240
            return "Follow present keeping span"
2241
        case .keepStartTimestamp:
2242
            return "Follow present keeping start"
2243
        }
2244
    }
2245

            
2246
    private func alignSelectionToLeadingEdge() {
2247
        let alignedRange = normalizedSelectionRange(
2248
            availableTimeRange.lowerBound...currentRange.upperBound
2249
        )
2250
        applySelection(alignedRange, pinToPresent: false)
2251
    }
2252

            
2253
    private func alignSelectionToTrailingEdge() {
2254
        let alignedRange = normalizedSelectionRange(
2255
            currentRange.lowerBound...availableTimeRange.upperBound
2256
        )
2257
        applySelection(alignedRange, pinToPresent: true)
2258
    }
2259

            
2260
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
2261
        DragGesture(minimumDistance: 0)
2262
            .onChanged { value in
2263
                updateSelectionDrag(value: value, totalWidth: totalWidth)
2264
            }
2265
            .onEnded { _ in
2266
                dragState = nil
2267
            }
2268
    }
2269

            
2270
    private func updateSelectionDrag(
2271
        value: DragGesture.Value,
2272
        totalWidth: CGFloat
2273
    ) {
2274
        let startingRange = resolvedSelectionRange()
2275

            
2276
        if dragState == nil {
2277
            dragState = DragState(
2278
                target: dragTarget(
2279
                    for: value.startLocation.x,
2280
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
2281
                ),
2282
                initialRange: startingRange
2283
            )
2284
        }
2285

            
2286
        guard let dragState else { return }
2287

            
2288
        let resultingRange = snappedToEdges(
2289
            adjustedRange(
2290
                from: dragState.initialRange,
2291
                target: dragState.target,
2292
                translationX: value.translation.width,
2293
                totalWidth: totalWidth
2294
            ),
2295
            target: dragState.target,
2296
            totalWidth: totalWidth
2297
        )
2298

            
2299
        applySelection(
2300
            resultingRange,
2301
            pinToPresent: shouldKeepPresentPin(
2302
                during: dragState.target,
2303
                initialRange: dragState.initialRange,
2304
                resultingRange: resultingRange
2305
            ),
2306
        )
2307
    }
2308

            
2309
    private func dragTarget(
2310
        for startX: CGFloat,
2311
        selectionFrame: CGRect
2312
    ) -> DragTarget {
2313
        let handleZone: CGFloat = compactLayout ? 20 : 24
2314

            
2315
        if abs(startX - selectionFrame.minX) <= handleZone {
2316
            return .lowerBound
2317
        }
2318

            
2319
        if abs(startX - selectionFrame.maxX) <= handleZone {
2320
            return .upperBound
2321
        }
2322

            
2323
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
2324
            return .window
2325
        }
2326

            
2327
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
2328
    }
2329

            
2330
    private func adjustedRange(
2331
        from initialRange: ClosedRange<Date>,
2332
        target: DragTarget,
2333
        translationX: CGFloat,
2334
        totalWidth: CGFloat
2335
    ) -> ClosedRange<Date> {
2336
        guard totalSpan > 0, totalWidth > 0 else {
2337
            return availableTimeRange
2338
        }
2339

            
2340
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
2341
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
2342

            
2343
        switch target {
2344
        case .lowerBound:
2345
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
2346
            let newLowerBound = min(
2347
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
2348
                maximumLowerBound
2349
            )
2350
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
2351

            
2352
        case .upperBound:
2353
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
2354
            let newUpperBound = max(
2355
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
2356
                minimumUpperBound
2357
            )
2358
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
2359

            
2360
        case .window:
2361
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
2362
            guard span < totalSpan else { return availableTimeRange }
2363

            
2364
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
2365
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
2366

            
2367
            if lowerBound < availableTimeRange.lowerBound {
2368
                upperBound = upperBound.addingTimeInterval(
2369
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2370
                )
2371
                lowerBound = availableTimeRange.lowerBound
2372
            }
2373

            
2374
            if upperBound > availableTimeRange.upperBound {
2375
                lowerBound = lowerBound.addingTimeInterval(
2376
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
2377
                )
2378
                upperBound = availableTimeRange.upperBound
2379
            }
2380

            
2381
            return normalizedSelectionRange(lowerBound...upperBound)
2382
        }
2383
    }
2384

            
2385
    private func snappedToEdges(
2386
        _ candidateRange: ClosedRange<Date>,
2387
        target: DragTarget,
2388
        totalWidth: CGFloat
2389
    ) -> ClosedRange<Date> {
2390
        guard totalSpan > 0 else {
2391
            return availableTimeRange
2392
        }
2393

            
2394
        let snapInterval = edgeSnapInterval(for: totalWidth)
2395
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
2396
        var lowerBound = candidateRange.lowerBound
2397
        var upperBound = candidateRange.upperBound
2398

            
2399
        if target != .upperBound,
2400
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
2401
            lowerBound = availableTimeRange.lowerBound
2402
            if target == .window {
2403
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
2404
            }
2405
        }
2406

            
2407
        if target != .lowerBound,
2408
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
2409
            upperBound = availableTimeRange.upperBound
2410
            if target == .window {
2411
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
2412
            }
2413
        }
2414

            
2415
        return normalizedSelectionRange(lowerBound...upperBound)
2416
    }
2417

            
2418
    private func edgeSnapInterval(
2419
        for totalWidth: CGFloat
2420
    ) -> TimeInterval {
2421
        guard totalWidth > 0 else { return minimumSelectionSpan }
2422

            
2423
        let snapWidth = min(
2424
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2425
            totalWidth * 0.18
2426
        )
2427
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2428
        return min(
2429
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2430
            totalSpan / 4
2431
        )
2432
    }
2433

            
2434
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2435
        guard let selectedTimeRange else { return availableTimeRange }
2436

            
2437
        if isPinnedToPresent {
2438
            switch presentTrackingMode {
2439
            case .keepDuration:
2440
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2441
                return normalizedSelectionRange(
2442
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2443
                )
2444
            case .keepStartTimestamp:
2445
                return normalizedSelectionRange(
2446
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2447
                )
2448
            }
2449
        }
2450

            
2451
        return normalizedSelectionRange(selectedTimeRange)
2452
    }
2453

            
2454
    private func normalizedSelectionRange(
2455
        _ candidateRange: ClosedRange<Date>
2456
    ) -> ClosedRange<Date> {
2457
        let availableSpan = totalSpan
2458
        guard availableSpan > 0 else { return availableTimeRange }
2459

            
2460
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2461
        let requestedSpan = min(
2462
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2463
            availableSpan
2464
        )
2465

            
2466
        if requestedSpan >= availableSpan {
2467
            return availableTimeRange
2468
        }
2469

            
2470
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2471
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2472

            
2473
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2474
            if lowerBound == availableTimeRange.lowerBound {
2475
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2476
            } else {
2477
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2478
            }
2479
        }
2480

            
2481
        if upperBound > availableTimeRange.upperBound {
2482
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2483
            upperBound = availableTimeRange.upperBound
2484
            lowerBound = lowerBound.addingTimeInterval(-delta)
2485
        }
2486

            
2487
        if lowerBound < availableTimeRange.lowerBound {
2488
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2489
            lowerBound = availableTimeRange.lowerBound
2490
            upperBound = upperBound.addingTimeInterval(delta)
2491
        }
2492

            
2493
        return lowerBound...upperBound
2494
    }
2495

            
2496
    private func shouldKeepPresentPin(
2497
        during target: DragTarget,
2498
        initialRange: ClosedRange<Date>,
2499
        resultingRange: ClosedRange<Date>
2500
    ) -> Bool {
2501
        let startedPinnedToPresent =
2502
            isPinnedToPresent ||
2503
            selectionCoversFullRange(initialRange)
2504

            
2505
        guard startedPinnedToPresent else {
2506
            return selectionTouchesPresent(resultingRange)
2507
        }
2508

            
2509
        switch target {
2510
        case .lowerBound:
2511
            return true
2512
        case .upperBound, .window:
2513
            return selectionTouchesPresent(resultingRange)
2514
        }
2515
    }
2516

            
2517
    private func applySelection(
2518
        _ candidateRange: ClosedRange<Date>,
2519
        pinToPresent: Bool
2520
    ) {
2521
        let normalizedRange = normalizedSelectionRange(candidateRange)
2522

            
2523
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2524
            selectedTimeRange = nil
2525
        } else {
2526
            selectedTimeRange = normalizedRange
2527
        }
2528

            
2529
        isPinnedToPresent = pinToPresent
2530
    }
2531

            
2532
    private func selectionTouchesPresent(
2533
        _ range: ClosedRange<Date>
2534
    ) -> Bool {
2535
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2536
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2537
    }
2538

            
2539
    private func selectionCoversFullRange(
2540
        _ range: ClosedRange<Date>
2541
    ) -> Bool {
2542
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2543
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2544
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2545
    }
2546

            
2547
    private func selectionFrame(in size: CGSize) -> CGRect {
2548
        selectionFrame(for: currentRange, width: size.width)
2549
    }
2550

            
2551
    private func selectionFrame(
2552
        for range: ClosedRange<Date>,
2553
        width: CGFloat
2554
    ) -> CGRect {
2555
        guard width > 0, totalSpan > 0 else {
2556
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2557
        }
2558

            
2559
        let minimumX = xPosition(for: range.lowerBound, width: width)
2560
        let maximumX = xPosition(for: range.upperBound, width: width)
2561
        return CGRect(
2562
            x: minimumX,
2563
            y: 0,
2564
            width: max(maximumX - minimumX, 2),
2565
            height: trackHeight
2566
        )
2567
    }
2568

            
2569
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2570
        guard width > 0, totalSpan > 0 else { return 0 }
2571

            
2572
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2573
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2574
        return CGFloat(normalizedOffset) * width
2575
    }
2576

            
2577
    private func boundaryLabel(for date: Date) -> String {
2578
        date.format(as: boundaryDateFormat)
2579
    }
2580

            
2581
    private var boundaryDateFormat: String {
2582
        switch totalSpan {
2583
        case 0..<86400:
2584
            return "HH:mm"
2585
        case 86400..<604800:
2586
            return "MMM d HH:mm"
2587
        default:
2588
            return "MMM d"
2589
        }
2590
    }
2591
}
2592

            
Bogdan Timofte authored 2 months ago
2593
struct Chart : View {
2594

            
Bogdan Timofte authored 2 months ago
2595
    @Environment(\.displayScale) private var displayScale
2596

            
Bogdan Timofte authored 2 months ago
2597
    let points: [Measurements.Measurement.Point]
2598
    let context: ChartContext
Bogdan Timofte authored 2 months ago
2599
    var areaChart: Bool = false
2600
    var strokeColor: Color = .black
Bogdan Timofte authored 2 months ago
2601
    var areaFillColor: Color? = nil
Bogdan Timofte authored 2 months ago
2602

            
2603
    var body : some View {
2604
        GeometryReader { geometry in
2605
            if self.areaChart {
Bogdan Timofte authored 2 months ago
2606
                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
Bogdan Timofte authored 2 months ago
2607
                self.path( geometry: geometry )
Bogdan Timofte authored 2 months ago
2608
                    .fill(
2609
                        LinearGradient(
2610
                            gradient: .init(
2611
                                colors: [
2612
                                    fillColor.opacity(0.72),
2613
                                    fillColor.opacity(0.18)
2614
                                ]
2615
                            ),
2616
                            startPoint: .init(x: 0.5, y: 0.08),
2617
                            endPoint: .init(x: 0.5, y: 0.92)
2618
                        )
2619
                    )
Bogdan Timofte authored 2 months ago
2620
            } else {
2621
                self.path( geometry: geometry )
2622
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2623
            }
2624
        }
2625
    }
2626

            
2627
    fileprivate func path(geometry: GeometryProxy) -> Path {
Bogdan Timofte authored 2 months ago
2628
        let displayedPoints = scaledPoints(for: geometry.size.width)
2629
        let baselineY = context.placeInRect(
2630
            point: CGPoint(x: context.origin.x, y: context.origin.y)
2631
        ).y * geometry.size.height
2632

            
Bogdan Timofte authored 2 months ago
2633
        return Path { path in
Bogdan Timofte authored 2 months ago
2634
            var firstRenderedPoint: CGPoint?
2635
            var lastRenderedPoint: CGPoint?
Bogdan Timofte authored 2 months ago
2636
            var needsMove = true
2637

            
Bogdan Timofte authored 2 months ago
2638
            for point in displayedPoints {
Bogdan Timofte authored 2 months ago
2639
                if point.isDiscontinuity {
Bogdan Timofte authored 2 months ago
2640
                    closeAreaSegment(
2641
                        in: &path,
2642
                        firstPoint: firstRenderedPoint,
2643
                        lastPoint: lastRenderedPoint,
2644
                        baselineY: baselineY
2645
                    )
2646
                    firstRenderedPoint = nil
2647
                    lastRenderedPoint = nil
Bogdan Timofte authored 2 months ago
2648
                    needsMove = true
2649
                    continue
2650
                }
2651

            
2652
                let item = context.placeInRect(point: point.point())
2653
                let renderedPoint = CGPoint(
2654
                    x: item.x * geometry.size.width,
2655
                    y: item.y * geometry.size.height
2656
                )
2657

            
2658
                if needsMove {
2659
                    path.move(to: renderedPoint)
Bogdan Timofte authored 2 months ago
2660
                    firstRenderedPoint = renderedPoint
Bogdan Timofte authored 2 months ago
2661
                    needsMove = false
2662
                } else {
2663
                    path.addLine(to: renderedPoint)
2664
                }
Bogdan Timofte authored 2 months ago
2665

            
2666
                lastRenderedPoint = renderedPoint
Bogdan Timofte authored 2 months ago
2667
            }
Bogdan Timofte authored 2 months ago
2668

            
Bogdan Timofte authored 2 months ago
2669
            closeAreaSegment(
2670
                in: &path,
2671
                firstPoint: firstRenderedPoint,
2672
                lastPoint: lastRenderedPoint,
2673
                baselineY: baselineY
2674
            )
2675
        }
2676
    }
2677

            
2678
    private func closeAreaSegment(
2679
        in path: inout Path,
2680
        firstPoint: CGPoint?,
2681
        lastPoint: CGPoint?,
2682
        baselineY: CGFloat
2683
    ) {
2684
        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2685

            
2686
        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2687
        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2688
        path.closeSubpath()
2689
    }
2690

            
2691
    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2692
        let sampleCount = points.reduce(into: 0) { partialResult, point in
2693
            if point.isSample {
2694
                partialResult += 1
2695
            }
2696
        }
2697

            
2698
        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2699
        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2700

            
2701
        guard sampleCount > maximumSamplesToRender, context.isValid else {
2702
            return points
2703
        }
2704

            
2705
        var scaledPoints: [Measurements.Measurement.Point] = []
2706
        var currentSegment: [Measurements.Measurement.Point] = []
2707

            
2708
        for point in points {
2709
            if point.isDiscontinuity {
2710
                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2711
                currentSegment.removeAll(keepingCapacity: true)
2712

            
2713
                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2714
                    appendScaledPoint(point, to: &scaledPoints)
2715
                }
2716
            } else {
2717
                currentSegment.append(point)
2718
            }
2719
        }
2720

            
2721
        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2722
        return scaledPoints.isEmpty ? points : scaledPoints
2723
    }
2724

            
2725
    private func appendScaledSegment(
2726
        _ segment: [Measurements.Measurement.Point],
2727
        to scaledPoints: inout [Measurements.Measurement.Point],
2728
        displayColumns: Int
2729
    ) {
2730
        guard !segment.isEmpty else { return }
2731

            
2732
        if segment.count <= max(displayColumns * 2, 120) {
2733
            for point in segment {
2734
                appendScaledPoint(point, to: &scaledPoints)
2735
            }
2736
            return
2737
        }
2738

            
2739
        var bucket: [Measurements.Measurement.Point] = []
2740
        var currentColumn: Int?
2741

            
2742
        for point in segment {
2743
            let column = displayColumn(for: point, totalColumns: displayColumns)
2744

            
2745
            if let currentColumn, currentColumn != column {
2746
                appendBucket(bucket, to: &scaledPoints)
2747
                bucket.removeAll(keepingCapacity: true)
Bogdan Timofte authored 2 months ago
2748
            }
Bogdan Timofte authored 2 months ago
2749

            
2750
            bucket.append(point)
2751
            currentColumn = column
2752
        }
2753

            
2754
        appendBucket(bucket, to: &scaledPoints)
2755
    }
2756

            
2757
    private func appendBucket(
2758
        _ bucket: [Measurements.Measurement.Point],
2759
        to scaledPoints: inout [Measurements.Measurement.Point]
2760
    ) {
2761
        guard !bucket.isEmpty else { return }
2762

            
2763
        if bucket.count <= 2 {
2764
            for point in bucket {
2765
                appendScaledPoint(point, to: &scaledPoints)
2766
            }
2767
            return
2768
        }
2769

            
2770
        let firstPoint = bucket.first!
2771
        let lastPoint = bucket.last!
2772
        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2773
        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2774

            
2775
        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2776
            .sorted { lhs, rhs in
2777
                if lhs.timestamp == rhs.timestamp {
2778
                    return lhs.id < rhs.id
2779
                }
2780
                return lhs.timestamp < rhs.timestamp
2781
            }
2782

            
2783
        var emittedPointIDs: Set<Int> = []
2784
        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2785
            appendScaledPoint(point, to: &scaledPoints)
Bogdan Timofte authored 2 months ago
2786
        }
2787
    }
Bogdan Timofte authored 2 months ago
2788

            
2789
    private func appendScaledPoint(
2790
        _ point: Measurements.Measurement.Point,
2791
        to scaledPoints: inout [Measurements.Measurement.Point]
2792
    ) {
2793
        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2794
                scaledPoints.last?.value == point.value &&
2795
                scaledPoints.last?.kind == point.kind) else {
2796
            return
2797
        }
2798

            
2799
        scaledPoints.append(
2800
            Measurements.Measurement.Point(
2801
                id: scaledPoints.count,
2802
                timestamp: point.timestamp,
2803
                value: point.value,
2804
                kind: point.kind
2805
            )
2806
        )
2807
    }
2808

            
2809
    private func displayColumn(
2810
        for point: Measurements.Measurement.Point,
2811
        totalColumns: Int
2812
    ) -> Int {
2813
        let totalColumns = max(totalColumns, 1)
2814
        let timeSpan = max(Double(context.size.width), 1)
2815
        let normalizedOffset = min(
2816
            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2817
            1
2818
        )
2819

            
2820
        return min(
2821
            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2822
            totalColumns - 1
2823
        )
2824
    }
Bogdan Timofte authored 2 months ago
2825

            
2826
}