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

            
9
import SwiftUI
10

            
11
struct MeasurementChartView: View {
Bogdan Timofte authored a week ago
12
    private enum SeriesKind {
13
        case power
14
        case voltage
15
        case current
Bogdan Timofte authored 4 days ago
16
        case temperature
Bogdan Timofte authored a week ago
17

            
18
        var unit: String {
19
            switch self {
20
            case .power: return "W"
21
            case .voltage: return "V"
22
            case .current: return "A"
Bogdan Timofte authored 4 days ago
23
            case .temperature: return ""
Bogdan Timofte authored a week ago
24
            }
25
        }
26

            
27
        var tint: Color {
28
            switch self {
29
            case .power: return .red
30
            case .voltage: return .green
31
            case .current: return .blue
Bogdan Timofte authored 4 days ago
32
            case .temperature: return .orange
Bogdan Timofte authored a week ago
33
            }
34
        }
35
    }
36

            
37
    private struct SeriesData {
38
        let kind: SeriesKind
39
        let points: [Measurements.Measurement.Point]
40
        let samplePoints: [Measurements.Measurement.Point]
41
        let context: ChartContext
42
        let autoLowerBound: Double
43
        let autoUpperBound: Double
44
        let maximumSampleValue: Double?
45
    }
46

            
Bogdan Timofte authored 2 weeks ago
47
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 weeks ago
48
    private let minimumVoltageSpan = 0.5
49
    private let minimumCurrentSpan = 0.5
50
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 4 days ago
51
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored a week ago
52
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
53

            
54
    let compactLayout: Bool
55
    let availableSize: CGSize
Bogdan Timofte authored 2 weeks ago
56

            
57
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored a week ago
58
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
59
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 weeks ago
60
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 weeks ago
61

            
62
    @State var displayVoltage: Bool = false
63
    @State var displayCurrent: Bool = false
64
    @State var displayPower: Bool = true
Bogdan Timofte authored 4 days ago
65
    @State var displayTemperature: Bool = false
Bogdan Timofte authored a week ago
66
    @State private var showResetConfirmation: Bool = false
67
    @State private var chartNow: Date = Date()
68
    @State private var pinOrigin: Bool = false
69
    @State private var useSharedOrigin: Bool = false
70
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 6 days ago
71
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored a week ago
72
    @State private var powerAxisOrigin: Double = 0
73
    @State private var voltageAxisOrigin: Double = 0
74
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 4 days ago
75
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored 2 weeks ago
76
    let xLabels: Int = 4
77
    let yLabels: Int = 4
78

            
Bogdan Timofte authored a week ago
79
    init(
80
        compactLayout: Bool = false,
81
        availableSize: CGSize = .zero,
82
        timeRange: ClosedRange<Date>? = nil
83
    ) {
84
        self.compactLayout = compactLayout
85
        self.availableSize = availableSize
86
        self.timeRange = timeRange
87
    }
88

            
89
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 6 days ago
90
        if compactLayout {
91
            return 38
92
        }
93
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored a week ago
94
    }
95

            
96
    private var chartSectionSpacing: CGFloat {
97
        compactLayout ? 6 : 8
98
    }
99

            
100
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 6 days ago
101
        if compactLayout {
102
            return 24
103
        }
104
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored a week ago
105
    }
106

            
Bogdan Timofte authored 6 days ago
107
    private var isPortraitLayout: Bool {
108
        guard availableSize != .zero else { return verticalSizeClass != .compact }
109
        return availableSize.height >= availableSize.width
110
    }
111

            
Bogdan Timofte authored 6 days ago
112
    private var isIPhone: Bool {
113
        #if os(iOS)
114
        return UIDevice.current.userInterfaceIdiom == .phone
115
        #else
116
        return false
117
        #endif
118
    }
119

            
120
    private enum OriginControlsPlacement {
121
        case aboveXAxisLegend
122
        case overXAxisLegend
123
        case belowXAxisLegend
124
    }
125

            
126
    private var originControlsPlacement: OriginControlsPlacement {
127
        if isIPhone {
128
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
129
        }
130
        return .belowXAxisLegend
131
    }
132

            
Bogdan Timofte authored a week ago
133
    private var plotSectionHeight: CGFloat {
134
        if availableSize == .zero {
Bogdan Timofte authored 6 days ago
135
            return compactLayout ? 300 : 380
136
        }
137

            
138
        if isPortraitLayout {
139
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
140
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
141
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored a week ago
142
        }
143

            
144
        if compactLayout {
145
            return min(max(availableSize.height * 0.36, 240), 300)
146
        }
147

            
148
        return min(max(availableSize.height * 0.5, 300), 440)
149
    }
150

            
151
    private var stackedToolbarLayout: Bool {
152
        if availableSize.width > 0 {
153
            return availableSize.width < 640
154
        }
155

            
156
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
157
    }
158

            
159
    private var showsLabeledOriginControls: Bool {
160
        !compactLayout && !stackedToolbarLayout
161
    }
162

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

            
Bogdan Timofte authored 6 days ago
170
        if availableSize.width > 0 {
171
            return availableSize.width >= 900 || availableSize.height >= 700
172
        }
173
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
174
    }
175

            
176
    private var chartBaseFont: Font {
Bogdan Timofte authored 6 days ago
177
        if isIPhone && isPortraitLayout {
178
            return .caption
179
        }
180
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 6 days ago
181
    }
182

            
Bogdan Timofte authored 6 days ago
183
    private var usesCompactLandscapeOriginControls: Bool {
184
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
185
    }
186

            
Bogdan Timofte authored 2 weeks ago
187
    var body: some View {
Bogdan Timofte authored a week ago
188
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
189
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
190
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 4 days ago
191
        let temperatureSeries = series(for: measurements.temperature, kind: .temperature, minimumYSpan: minimumTemperatureSpan)
Bogdan Timofte authored 2 weeks ago
192
        let primarySeries = displayedPrimarySeries(
193
            powerSeries: powerSeries,
194
            voltageSeries: voltageSeries,
195
            currentSeries: currentSeries
196
        )
197

            
Bogdan Timofte authored 2 weeks ago
198
        Group {
Bogdan Timofte authored 2 weeks ago
199
            if let primarySeries {
200
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
201
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
202

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

            
206
                        VStack(spacing: 6) {
207
                            HStack(spacing: chartSectionSpacing) {
208
                                primaryAxisView(
209
                                    height: plotHeight,
210
                                    powerSeries: powerSeries,
211
                                    voltageSeries: voltageSeries,
212
                                    currentSeries: currentSeries
213
                                )
214
                                .frame(width: axisColumnWidth, height: plotHeight)
215

            
216
                                ZStack {
217
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
218
                                        .fill(Color.primary.opacity(0.05))
219

            
220
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
221
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
222

            
223
                                    horizontalGuides(context: primarySeries.context)
224
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
225
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
226
                                    renderedChart(
227
                                        powerSeries: powerSeries,
228
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 4 days ago
229
                                        currentSeries: currentSeries,
230
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 weeks ago
231
                                    )
Bogdan Timofte authored 2 weeks ago
232
                                }
Bogdan Timofte authored 2 weeks ago
233
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
234
                                .frame(maxWidth: .infinity)
235
                                .frame(height: plotHeight)
236

            
237
                                secondaryAxisView(
238
                                    height: plotHeight,
239
                                    powerSeries: powerSeries,
240
                                    voltageSeries: voltageSeries,
Bogdan Timofte authored 4 days ago
241
                                    currentSeries: currentSeries,
242
                                    temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 weeks ago
243
                                )
244
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
245
                            }
Bogdan Timofte authored 6 days ago
246
                            .overlay(alignment: .bottom) {
Bogdan Timofte authored 6 days ago
247
                                if originControlsPlacement == .aboveXAxisLegend {
248
                                    scaleControlsPill(
249
                                        voltageSeries: voltageSeries,
250
                                        currentSeries: currentSeries
251
                                    )
252
                                    .padding(.bottom, compactLayout ? 6 : 10)
253
                                }
Bogdan Timofte authored 6 days ago
254
                            }
Bogdan Timofte authored 2 weeks ago
255

            
Bogdan Timofte authored 6 days ago
256
                            switch originControlsPlacement {
257
                            case .aboveXAxisLegend:
258
                                xAxisLabelsView(context: primarySeries.context)
259
                                    .frame(height: xAxisHeight)
260
                            case .overXAxisLegend:
261
                                xAxisLabelsView(context: primarySeries.context)
262
                                    .frame(height: xAxisHeight)
263
                                    .overlay(alignment: .center) {
264
                                        scaleControlsPill(
265
                                            voltageSeries: voltageSeries,
266
                                            currentSeries: currentSeries
267
                                        )
Bogdan Timofte authored 6 days ago
268
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 6 days ago
269
                                    }
270
                            case .belowXAxisLegend:
271
                                xAxisLabelsView(context: primarySeries.context)
272
                                    .frame(height: xAxisHeight)
273

            
274
                                HStack {
275
                                    Spacer(minLength: 0)
276
                                    scaleControlsPill(
277
                                        voltageSeries: voltageSeries,
278
                                        currentSeries: currentSeries
279
                                    )
280
                                    Spacer(minLength: 0)
281
                                }
282
                            }
Bogdan Timofte authored 2 weeks ago
283
                        }
Bogdan Timofte authored 2 weeks ago
284
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
285
                    }
Bogdan Timofte authored a week ago
286
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 weeks ago
287
                }
Bogdan Timofte authored 2 weeks ago
288
            } else {
289
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
290
                    chartToggleBar()
Bogdan Timofte authored a week ago
291
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 weeks ago
292
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
293
                }
294
            }
Bogdan Timofte authored 2 weeks ago
295
        }
Bogdan Timofte authored 6 days ago
296
        .font(chartBaseFont)
Bogdan Timofte authored 2 weeks ago
297
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a week ago
298
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
299
            guard timeRange == nil else { return }
300
            chartNow = now
301
        }
Bogdan Timofte authored 2 weeks ago
302
    }
303

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

            
Bogdan Timofte authored 6 days ago
308
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
309
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
310
        }
311
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
312
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
313
        .background(
314
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
315
                .fill(Color.primary.opacity(0.045))
316
        )
317
        .overlay(
318
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
319
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
320
        )
Bogdan Timofte authored a week ago
321

            
Bogdan Timofte authored 6 days ago
322
        return Group {
Bogdan Timofte authored a week ago
323
            if stackedToolbarLayout {
Bogdan Timofte authored 6 days ago
324
                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
325
                    controlsPanel
326
                    HStack {
327
                        Spacer(minLength: 0)
328
                        resetBufferButton(condensedLayout: condensedLayout)
329
                    }
Bogdan Timofte authored 2 weeks ago
330
                }
Bogdan Timofte authored a week ago
331
            } else {
Bogdan Timofte authored 6 days ago
332
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
333
                    controlsPanel
Bogdan Timofte authored a week ago
334
                    Spacer(minLength: 0)
335
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
336
                }
Bogdan Timofte authored a week ago
337
            }
338
        }
339
        .frame(maxWidth: .infinity, alignment: .leading)
340
    }
341

            
Bogdan Timofte authored 6 days ago
342
    private var shouldFloatScaleControlsOverChart: Bool {
343
        #if os(iOS)
344
        if availableSize.width > 0, availableSize.height > 0 {
345
            return availableSize.width > availableSize.height
346
        }
347
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
348
        #else
349
        return false
350
        #endif
351
    }
352

            
353
    private func scaleControlsPill(
354
        voltageSeries: SeriesData,
355
        currentSeries: SeriesData
356
    ) -> some View {
357
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 6 days ago
358
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 6 days ago
359
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
360
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 6 days ago
361

            
362
        return originControlsRow(
363
            voltageSeries: voltageSeries,
364
            currentSeries: currentSeries,
365
            condensedLayout: condensedLayout,
366
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
367
        )
Bogdan Timofte authored 6 days ago
368
        .padding(.horizontal, horizontalPadding)
369
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 6 days ago
370
        .background(
371
            Capsule(style: .continuous)
Bogdan Timofte authored 6 days ago
372
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 6 days ago
373
        )
374
        .overlay(
375
            Capsule(style: .continuous)
376
                .stroke(
Bogdan Timofte authored 6 days ago
377
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 6 days ago
378
                    lineWidth: 1
379
                )
380
        )
381
    }
382

            
Bogdan Timofte authored a week ago
383
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
384
        HStack(spacing: condensedLayout ? 6 : 8) {
385
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
386
                displayVoltage.toggle()
387
                if displayVoltage {
388
                    displayPower = false
Bogdan Timofte authored 4 days ago
389
                    if displayTemperature && displayCurrent {
390
                        displayCurrent = false
391
                    }
Bogdan Timofte authored a week ago
392
                }
393
            }
394

            
395
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
396
                displayCurrent.toggle()
397
                if displayCurrent {
398
                    displayPower = false
Bogdan Timofte authored 4 days ago
399
                    if displayTemperature && displayVoltage {
400
                        displayVoltage = false
401
                    }
Bogdan Timofte authored 2 weeks ago
402
                }
Bogdan Timofte authored a week ago
403
            }
404

            
405
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
406
                displayPower.toggle()
407
                if displayPower {
408
                    displayCurrent = false
409
                    displayVoltage = false
410
                }
411
            }
Bogdan Timofte authored 4 days ago
412

            
413
            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
414
                displayTemperature.toggle()
415
                if displayTemperature && displayVoltage && displayCurrent {
416
                    displayCurrent = false
417
                }
418
            }
Bogdan Timofte authored a week ago
419
        }
420
    }
421

            
422
    private func originControlsRow(
423
        voltageSeries: SeriesData,
424
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
425
        condensedLayout: Bool,
426
        showsLabel: Bool
Bogdan Timofte authored a week ago
427
    ) -> some View {
Bogdan Timofte authored 6 days ago
428
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
429
            if supportsSharedOrigin {
430
                symbolControlChip(
431
                    systemImage: "equal.circle",
432
                    enabled: true,
433
                    active: useSharedOrigin,
434
                    condensedLayout: condensedLayout,
435
                    showsLabel: showsLabel,
436
                    label: "Match Y Scale",
437
                    accessibilityLabel: "Match Y scale"
438
                ) {
439
                    toggleSharedOrigin(
440
                        voltageSeries: voltageSeries,
441
                        currentSeries: currentSeries
442
                    )
443
                }
Bogdan Timofte authored a week ago
444
            }
445

            
446
            symbolControlChip(
447
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
448
                enabled: true,
449
                active: pinOrigin,
450
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
451
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
452
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
453
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
454
            ) {
455
                togglePinnedOrigin(
456
                    voltageSeries: voltageSeries,
457
                    currentSeries: currentSeries
458
                )
459
            }
460

            
Bogdan Timofte authored 6 days ago
461
            if !pinnedOriginIsZero {
462
                symbolControlChip(
463
                    systemImage: "0.circle",
464
                    enabled: true,
465
                    active: false,
466
                    condensedLayout: condensedLayout,
467
                    showsLabel: showsLabel,
468
                    label: "Origin 0",
469
                    accessibilityLabel: "Set origin to zero"
470
                ) {
471
                    setVisibleOriginsToZero()
472
                }
Bogdan Timofte authored a week ago
473
            }
Bogdan Timofte authored 6 days ago
474

            
Bogdan Timofte authored a week ago
475
        }
476
    }
477

            
478
    private func seriesToggleButton(
479
        title: String,
480
        isOn: Bool,
481
        condensedLayout: Bool,
482
        action: @escaping () -> Void
483
    ) -> some View {
484
        Button(action: action) {
485
            Text(title)
Bogdan Timofte authored 6 days ago
486
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
487
                .lineLimit(1)
488
                .minimumScaleFactor(0.82)
489
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 6 days ago
490
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
491
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
492
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored a week ago
493
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
494
                .background(
495
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
496
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
497
                )
498
                .overlay(
499
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
500
                        .stroke(Color.blue, lineWidth: 1.5)
501
                )
502
        }
503
        .buttonStyle(.plain)
504
    }
505

            
506
    private func symbolControlChip(
507
        systemImage: String,
508
        enabled: Bool,
509
        active: Bool,
510
        condensedLayout: Bool,
511
        showsLabel: Bool,
512
        label: String,
513
        accessibilityLabel: String,
514
        action: @escaping () -> Void
515
    ) -> some View {
516
        Button(action: {
517
            action()
518
        }) {
519
            Group {
520
                if showsLabel {
521
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 6 days ago
522
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 6 days ago
523
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
524
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored a week ago
525
                } else {
526
                    Image(systemName: systemImage)
Bogdan Timofte authored 6 days ago
527
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 6 days ago
528
                        .frame(
Bogdan Timofte authored 6 days ago
529
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
530
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 6 days ago
531
                        )
Bogdan Timofte authored a week ago
532
                }
533
            }
534
                .background(
535
                    Capsule(style: .continuous)
536
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
537
                )
538
        }
539
        .buttonStyle(.plain)
540
        .foregroundColor(enabled ? .primary : .secondary)
541
        .opacity(enabled ? 1 : 0.55)
542
        .accessibilityLabel(accessibilityLabel)
543
    }
544

            
545
    private func resetBufferButton(condensedLayout: Bool) -> some View {
546
        Button(action: {
547
            showResetConfirmation = true
548
        }) {
549
            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
Bogdan Timofte authored 6 days ago
550
                .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
551
                .padding(.horizontal, condensedLayout ? 14 : 16)
Bogdan Timofte authored 6 days ago
552
                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
Bogdan Timofte authored a week ago
553
        }
554
        .buttonStyle(.plain)
555
        .foregroundColor(.white)
556
        .background(
557
            Capsule(style: .continuous)
558
                .fill(Color.red.opacity(0.8))
559
        )
560
        .fixedSize(horizontal: true, vertical: false)
561
        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
562
            Button("Reset series", role: .destructive) {
563
                measurements.resetSeries()
564
            }
565
            Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 weeks ago
566
        }
567
    }
568

            
Bogdan Timofte authored 6 days ago
569
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
570
        if isLargeDisplay {
571
            return .body.weight(.semibold)
572
        }
573
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
574
    }
575

            
576
    private func controlChipFont(condensedLayout: Bool) -> Font {
577
        if isLargeDisplay {
578
            return .callout.weight(.semibold)
579
        }
580
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
581
    }
582

            
Bogdan Timofte authored 2 weeks ago
583
    @ViewBuilder
584
    private func primaryAxisView(
585
        height: CGFloat,
Bogdan Timofte authored a week ago
586
        powerSeries: SeriesData,
587
        voltageSeries: SeriesData,
588
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
589
    ) -> some View {
590
        if displayPower {
591
            yAxisLabelsView(
592
                height: height,
593
                context: powerSeries.context,
Bogdan Timofte authored a week ago
594
                seriesKind: .power,
595
                measurementUnit: powerSeries.kind.unit,
596
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
597
            )
598
        } else if displayVoltage {
599
            yAxisLabelsView(
600
                height: height,
601
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
602
                seriesKind: .voltage,
603
                measurementUnit: voltageSeries.kind.unit,
604
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
605
            )
606
        } else if displayCurrent {
607
            yAxisLabelsView(
608
                height: height,
609
                context: currentSeries.context,
Bogdan Timofte authored a week ago
610
                seriesKind: .current,
611
                measurementUnit: currentSeries.kind.unit,
612
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
613
            )
614
        }
615
    }
616

            
617
    @ViewBuilder
618
    private func renderedChart(
Bogdan Timofte authored a week ago
619
        powerSeries: SeriesData,
620
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
621
        currentSeries: SeriesData,
622
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
623
    ) -> some View {
624
        if self.displayPower {
625
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
626
                .opacity(0.72)
627
        } else {
628
            if self.displayVoltage {
629
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
630
                    .opacity(0.78)
631
            }
632
            if self.displayCurrent {
633
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
634
                    .opacity(0.78)
635
            }
636
        }
Bogdan Timofte authored 4 days ago
637

            
638
        if displayTemperature {
639
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
640
                .opacity(0.86)
641
        }
Bogdan Timofte authored 2 weeks ago
642
    }
643

            
644
    @ViewBuilder
645
    private func secondaryAxisView(
646
        height: CGFloat,
Bogdan Timofte authored a week ago
647
        powerSeries: SeriesData,
648
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
649
        currentSeries: SeriesData,
650
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
651
    ) -> some View {
Bogdan Timofte authored 4 days ago
652
        if displayTemperature {
653
            yAxisLabelsView(
654
                height: height,
655
                context: temperatureSeries.context,
656
                seriesKind: .temperature,
657
                measurementUnit: measurementUnit(for: .temperature),
658
                tint: temperatureSeries.kind.tint
659
            )
660
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 weeks ago
661
            yAxisLabelsView(
662
                height: height,
663
                context: currentSeries.context,
Bogdan Timofte authored a week ago
664
                seriesKind: .current,
665
                measurementUnit: currentSeries.kind.unit,
666
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
667
            )
668
        } else {
669
            primaryAxisView(
670
                height: height,
671
                powerSeries: powerSeries,
672
                voltageSeries: voltageSeries,
673
                currentSeries: currentSeries
674
            )
Bogdan Timofte authored 2 weeks ago
675
        }
676
    }
Bogdan Timofte authored 2 weeks ago
677

            
678
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
679
        powerSeries: SeriesData,
680
        voltageSeries: SeriesData,
681
        currentSeries: SeriesData
682
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
683
        if displayPower {
Bogdan Timofte authored a week ago
684
            return powerSeries
Bogdan Timofte authored 2 weeks ago
685
        }
686
        if displayVoltage {
Bogdan Timofte authored a week ago
687
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
688
        }
689
        if displayCurrent {
Bogdan Timofte authored a week ago
690
            return currentSeries
Bogdan Timofte authored 2 weeks ago
691
        }
692
        return nil
693
    }
694

            
695
    private func series(
696
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
697
        kind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
698
        minimumYSpan: Double
Bogdan Timofte authored a week ago
699
    ) -> SeriesData {
Bogdan Timofte authored 2 weeks ago
700
        let points = measurement.points.filter { point in
701
            guard let timeRange else { return true }
702
            return timeRange.contains(point.timestamp)
703
        }
Bogdan Timofte authored a week ago
704
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
705
        let context = ChartContext()
Bogdan Timofte authored a week ago
706

            
707
        let autoBounds = automaticYBounds(
708
            for: samplePoints,
709
            minimumYSpan: minimumYSpan
710
        )
711
        let xBounds = xBounds(for: samplePoints)
712
        let lowerBound = resolvedLowerBound(
713
            for: kind,
714
            autoLowerBound: autoBounds.lowerBound
715
        )
716
        let upperBound = resolvedUpperBound(
717
            for: kind,
718
            lowerBound: lowerBound,
719
            autoUpperBound: autoBounds.upperBound,
720
            maximumSampleValue: samplePoints.map(\.value).max(),
721
            minimumYSpan: minimumYSpan
722
        )
723

            
724
        context.setBounds(
725
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
726
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
727
            yMin: CGFloat(lowerBound),
728
            yMax: CGFloat(upperBound)
729
        )
730

            
731
        return SeriesData(
732
            kind: kind,
733
            points: points,
734
            samplePoints: samplePoints,
735
            context: context,
736
            autoLowerBound: autoBounds.lowerBound,
737
            autoUpperBound: autoBounds.upperBound,
738
            maximumSampleValue: samplePoints.map(\.value).max()
739
        )
740
    }
741

            
742
    private var supportsSharedOrigin: Bool {
743
        displayVoltage && displayCurrent && !displayPower
744
    }
745

            
Bogdan Timofte authored 6 days ago
746
    private var minimumSharedScaleSpan: Double {
747
        max(minimumVoltageSpan, minimumCurrentSpan)
748
    }
749

            
Bogdan Timofte authored a week ago
750
    private var pinnedOriginIsZero: Bool {
751
        if useSharedOrigin && supportsSharedOrigin {
752
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
753
        }
Bogdan Timofte authored a week ago
754

            
755
        if displayPower {
756
            return pinOrigin && powerAxisOrigin == 0
757
        }
758

            
759
        let visibleOrigins = [
760
            displayVoltage ? voltageAxisOrigin : nil,
761
            displayCurrent ? currentAxisOrigin : nil
762
        ]
763
        .compactMap { $0 }
764

            
765
        guard !visibleOrigins.isEmpty else { return false }
766
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
767
    }
768

            
769
    private func toggleSharedOrigin(
770
        voltageSeries: SeriesData,
771
        currentSeries: SeriesData
772
    ) {
773
        guard supportsSharedOrigin else { return }
774

            
775
        if useSharedOrigin {
776
            useSharedOrigin = false
777
            return
778
        }
779

            
780
        captureCurrentOrigins(
781
            voltageSeries: voltageSeries,
782
            currentSeries: currentSeries
783
        )
784
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
785
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
786
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
787
        useSharedOrigin = true
788
        pinOrigin = true
789
    }
790

            
791
    private func togglePinnedOrigin(
792
        voltageSeries: SeriesData,
793
        currentSeries: SeriesData
794
    ) {
795
        if pinOrigin {
796
            pinOrigin = false
797
            return
798
        }
799

            
800
        captureCurrentOrigins(
801
            voltageSeries: voltageSeries,
802
            currentSeries: currentSeries
803
        )
804
        pinOrigin = true
805
    }
806

            
807
    private func setVisibleOriginsToZero() {
808
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
809
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
810
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
811
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
812
            voltageAxisOrigin = 0
813
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
814
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
815
        } else {
816
            if displayPower {
817
                powerAxisOrigin = 0
818
            }
819
            if displayVoltage {
820
                voltageAxisOrigin = 0
821
            }
822
            if displayCurrent {
823
                currentAxisOrigin = 0
824
            }
Bogdan Timofte authored 4 days ago
825
            if displayTemperature {
826
                temperatureAxisOrigin = 0
827
            }
Bogdan Timofte authored a week ago
828
        }
829

            
830
        pinOrigin = true
831
    }
832

            
833
    private func captureCurrentOrigins(
834
        voltageSeries: SeriesData,
835
        currentSeries: SeriesData
836
    ) {
837
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
838
        voltageAxisOrigin = voltageSeries.autoLowerBound
839
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 4 days ago
840
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a week ago
841
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
842
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
843
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
844
    }
845

            
846
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
847
        switch kind {
848
        case .power:
849
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
850
        case .voltage:
851
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
852
                return sharedAxisOrigin
853
            }
854
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
855
        case .current:
856
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
857
                return sharedAxisOrigin
858
            }
859
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
Bogdan Timofte authored 4 days ago
860
        case .temperature:
861
            return pinOrigin ? temperatureAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.temperature), minimumYSpan: minimumTemperatureSpan).lowerBound
Bogdan Timofte authored a week ago
862
        }
863
    }
864

            
865
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
866
        measurement.points.filter { point in
867
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
868
        }
869
    }
870

            
871
    private func xBounds(
872
        for samplePoints: [Measurements.Measurement.Point]
873
    ) -> ClosedRange<Date> {
874
        if let timeRange {
875
            return timeRange
876
        }
877

            
878
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
879
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
880

            
881
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
882
            return lowerBound...upperBound
883
        }
884

            
885
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
886
    }
887

            
888
    private func automaticYBounds(
889
        for samplePoints: [Measurements.Measurement.Point],
890
        minimumYSpan: Double
891
    ) -> (lowerBound: Double, upperBound: Double) {
892
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
893

            
894
        guard
895
            let minimumSampleValue = samplePoints.map(\.value).min(),
896
            let maximumSampleValue = samplePoints.map(\.value).max()
897
        else {
898
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
899
        }
Bogdan Timofte authored a week ago
900

            
901
        var lowerBound = minimumSampleValue
902
        var upperBound = maximumSampleValue
903
        let currentSpan = upperBound - lowerBound
904

            
905
        if currentSpan < minimumYSpan {
906
            let expansion = (minimumYSpan - currentSpan) / 2
907
            lowerBound -= expansion
908
            upperBound += expansion
909
        }
910

            
911
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
912
            let shift = -negativeAllowance - lowerBound
913
            lowerBound += shift
914
            upperBound += shift
915
        }
916

            
917
        let snappedLowerBound = snappedOriginValue(lowerBound)
918
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
919
        return (snappedLowerBound, resolvedUpperBound)
920
    }
921

            
922
    private func resolvedLowerBound(
923
        for kind: SeriesKind,
924
        autoLowerBound: Double
925
    ) -> Double {
926
        guard pinOrigin else { return autoLowerBound }
927

            
928
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
929
            return sharedAxisOrigin
930
        }
931

            
932
        switch kind {
933
        case .power:
934
            return powerAxisOrigin
935
        case .voltage:
936
            return voltageAxisOrigin
937
        case .current:
938
            return currentAxisOrigin
Bogdan Timofte authored 4 days ago
939
        case .temperature:
940
            return temperatureAxisOrigin
Bogdan Timofte authored a week ago
941
        }
942
    }
943

            
944
    private func resolvedUpperBound(
945
        for kind: SeriesKind,
946
        lowerBound: Double,
947
        autoUpperBound: Double,
948
        maximumSampleValue: Double?,
949
        minimumYSpan: Double
950
    ) -> Double {
951
        guard pinOrigin else {
952
            return autoUpperBound
953
        }
954

            
Bogdan Timofte authored 6 days ago
955
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
956
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
957
        }
958

            
Bogdan Timofte authored 4 days ago
959
        if kind == .temperature {
960
            return autoUpperBound
961
        }
962

            
Bogdan Timofte authored a week ago
963
        return max(
964
            maximumSampleValue ?? lowerBound,
965
            lowerBound + minimumYSpan,
966
            autoUpperBound
967
        )
968
    }
969

            
Bogdan Timofte authored 6 days ago
970
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
971
        let baseline = displayedLowerBoundForSeries(kind)
972
        let proposedOrigin = snappedOriginValue(baseline + delta)
973

            
974
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
975
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
976
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
977
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
978
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
979
        } else {
980
            switch kind {
981
            case .power:
982
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
983
            case .voltage:
984
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
985
            case .current:
986
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 4 days ago
987
            case .temperature:
988
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a week ago
989
            }
990
        }
991

            
992
        pinOrigin = true
993
    }
994

            
Bogdan Timofte authored 6 days ago
995
    private func clearOriginOffset(for kind: SeriesKind) {
996
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
997
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
998
            sharedAxisOrigin = 0
999
            sharedAxisUpperBound = currentSpan
1000
            ensureSharedScaleSpan()
1001
            voltageAxisOrigin = 0
1002
            currentAxisOrigin = 0
1003
        } else {
1004
            switch kind {
1005
            case .power:
1006
                powerAxisOrigin = 0
1007
            case .voltage:
1008
                voltageAxisOrigin = 0
1009
            case .current:
1010
                currentAxisOrigin = 0
Bogdan Timofte authored 4 days ago
1011
            case .temperature:
1012
                temperatureAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1013
            }
1014
        }
1015

            
1016
        pinOrigin = true
1017
    }
1018

            
1019
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1020
        guard totalHeight > 1 else { return }
1021

            
1022
        let normalized = max(0, min(1, locationY / totalHeight))
1023
        if normalized < (1.0 / 3.0) {
1024
            applyOriginDelta(-1, kind: kind)
1025
        } else if normalized < (2.0 / 3.0) {
1026
            clearOriginOffset(for: kind)
1027
        } else {
1028
            applyOriginDelta(1, kind: kind)
1029
        }
1030
    }
1031

            
Bogdan Timofte authored a week ago
1032
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
1033
        switch kind {
1034
        case .power:
1035
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
1036
        case .voltage:
1037
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
1038
        case .current:
1039
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
Bogdan Timofte authored 4 days ago
1040
        case .temperature:
1041
            return snappedOriginValue(filteredSamplePoints(measurements.temperature).map(\.value).min() ?? 0)
Bogdan Timofte authored a week ago
1042
        }
1043
    }
1044

            
1045
    private func maximumVisibleSharedOrigin() -> Double {
1046
        min(
1047
            maximumVisibleOrigin(for: .voltage),
1048
            maximumVisibleOrigin(for: .current)
1049
        )
1050
    }
1051

            
Bogdan Timofte authored 4 days ago
1052
    private func measurementUnit(for kind: SeriesKind) -> String {
1053
        switch kind {
1054
        case .temperature:
1055
            let locale = Locale.autoupdatingCurrent
1056
            if #available(iOS 16.0, *) {
1057
                switch locale.measurementSystem {
1058
                case .us:
1059
                    return "°F"
1060
                default:
1061
                    return "°C"
1062
                }
1063
            }
1064

            
1065
            let regionCode = locale.regionCode ?? ""
1066
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1067
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1068
        default:
1069
            return kind.unit
1070
        }
1071
    }
1072

            
Bogdan Timofte authored 6 days ago
1073
    private func ensureSharedScaleSpan() {
1074
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1075
    }
1076

            
Bogdan Timofte authored a week ago
1077
    private func snappedOriginValue(_ value: Double) -> Double {
1078
        if value >= 0 {
1079
            return value.rounded(.down)
1080
        }
1081

            
1082
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
1083
    }
Bogdan Timofte authored 2 weeks ago
1084

            
1085
    private func yGuidePosition(
1086
        for labelIndex: Int,
1087
        context: ChartContext,
1088
        height: CGFloat
1089
    ) -> CGFloat {
1090
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1091
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1092
        return context.placeInRect(point: anchorPoint).y * height
1093
    }
1094

            
1095
    private func xGuidePosition(
1096
        for labelIndex: Int,
1097
        context: ChartContext,
1098
        width: CGFloat
1099
    ) -> CGFloat {
1100
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1101
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1102
        return context.placeInRect(point: anchorPoint).x * width
1103
    }
Bogdan Timofte authored 2 weeks ago
1104

            
1105
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
1106
    fileprivate func xAxisLabelsView(
1107
        context: ChartContext
1108
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
1109
        var timeFormat: String?
1110
        switch context.size.width {
1111
        case 0..<3600: timeFormat = "HH:mm:ss"
1112
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
1113
        default: timeFormat = "E HH:mm"
1114
        }
1115
        let labels = (1...xLabels).map {
1116
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
1117
        }
Bogdan Timofte authored 6 days ago
1118
        let axisLabelFont: Font = {
1119
            if isIPhone && isPortraitLayout {
1120
                return .caption2.weight(.semibold)
1121
            }
1122
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1123
        }()
Bogdan Timofte authored 2 weeks ago
1124

            
1125
        return HStack(spacing: chartSectionSpacing) {
1126
            Color.clear
1127
                .frame(width: axisColumnWidth)
1128

            
1129
            GeometryReader { geometry in
1130
                let labelWidth = max(
1131
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1132
                    1
1133
                )
1134

            
1135
                ZStack(alignment: .topLeading) {
1136
                    Path { path in
1137
                        for labelIndex in 1...self.xLabels {
1138
                            let x = xGuidePosition(
1139
                                for: labelIndex,
1140
                                context: context,
1141
                                width: geometry.size.width
1142
                            )
1143
                            path.move(to: CGPoint(x: x, y: 0))
1144
                            path.addLine(to: CGPoint(x: x, y: 6))
1145
                        }
1146
                    }
1147
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1148

            
1149
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1150
                        let labelIndex = item.offset + 1
1151
                        let centerX = xGuidePosition(
1152
                            for: labelIndex,
1153
                            context: context,
1154
                            width: geometry.size.width
1155
                        )
1156

            
1157
                        Text(item.element)
Bogdan Timofte authored 6 days ago
1158
                            .font(axisLabelFont)
Bogdan Timofte authored 2 weeks ago
1159
                            .monospacedDigit()
1160
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1161
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1162
                            .frame(width: labelWidth)
1163
                            .position(
1164
                                x: centerX,
1165
                                y: geometry.size.height * 0.7
1166
                            )
Bogdan Timofte authored 2 weeks ago
1167
                    }
1168
                }
Bogdan Timofte authored 2 weeks ago
1169
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1170
            }
Bogdan Timofte authored 2 weeks ago
1171

            
1172
            Color.clear
1173
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1174
        }
1175
    }
1176

            
Bogdan Timofte authored a week ago
1177
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1178
        height: CGFloat,
1179
        context: ChartContext,
Bogdan Timofte authored a week ago
1180
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1181
        measurementUnit: String,
1182
        tint: Color
1183
    ) -> some View {
Bogdan Timofte authored 6 days ago
1184
        let yAxisFont: Font = {
1185
            if isIPhone && isPortraitLayout {
1186
                return .caption2.weight(.semibold)
1187
            }
1188
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1189
        }()
1190

            
1191
        let unitFont: Font = {
1192
            if isIPhone && isPortraitLayout {
1193
                return .caption2.weight(.bold)
1194
            }
1195
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1196
        }()
1197

            
1198
        return GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1199
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1200
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1201
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1202

            
Bogdan Timofte authored 2 weeks ago
1203
            ZStack(alignment: .top) {
1204
                ForEach(0..<yLabels, id: \.self) { row in
1205
                    let labelIndex = yLabels - row
1206

            
1207
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1208
                        .font(yAxisFont)
Bogdan Timofte authored 2 weeks ago
1209
                        .monospacedDigit()
1210
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1211
                        .minimumScaleFactor(0.8)
1212
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1213
                        .position(
1214
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1215
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1216
                                for: labelIndex,
1217
                                context: context,
Bogdan Timofte authored 6 days ago
1218
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1219
                            )
1220
                        )
Bogdan Timofte authored 2 weeks ago
1221
                }
Bogdan Timofte authored 2 weeks ago
1222

            
Bogdan Timofte authored 2 weeks ago
1223
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1224
                    .font(unitFont)
Bogdan Timofte authored 2 weeks ago
1225
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1226
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1227
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1228
                    .background(
1229
                        Capsule(style: .continuous)
1230
                            .fill(tint.opacity(0.14))
1231
                    )
Bogdan Timofte authored 6 days ago
1232
                    .padding(.top, 8)
1233

            
Bogdan Timofte authored 2 weeks ago
1234
            }
1235
        }
Bogdan Timofte authored 2 weeks ago
1236
        .frame(height: height)
1237
        .background(
1238
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1239
                .fill(tint.opacity(0.12))
1240
        )
1241
        .overlay(
1242
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1243
                .stroke(tint.opacity(0.20), lineWidth: 1)
1244
        )
Bogdan Timofte authored a week ago
1245
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1246
        .gesture(
Bogdan Timofte authored 6 days ago
1247
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1248
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1249
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1250
                }
1251
        )
Bogdan Timofte authored 2 weeks ago
1252
    }
1253

            
Bogdan Timofte authored 2 weeks ago
1254
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1255
        GeometryReader { geometry in
1256
            Path { path in
Bogdan Timofte authored 2 weeks ago
1257
                for labelIndex in 1...self.yLabels {
1258
                    let y = yGuidePosition(
1259
                        for: labelIndex,
1260
                        context: context,
1261
                        height: geometry.size.height
1262
                    )
1263
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1264
                }
Bogdan Timofte authored 2 weeks ago
1265
            }
1266
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1267
        }
1268
    }
1269

            
Bogdan Timofte authored 2 weeks ago
1270
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1271
        GeometryReader { geometry in
1272
            Path { path in
1273

            
Bogdan Timofte authored 2 weeks ago
1274
                for labelIndex in 2..<self.xLabels {
1275
                    let x = xGuidePosition(
1276
                        for: labelIndex,
1277
                        context: context,
1278
                        width: geometry.size.width
1279
                    )
1280
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1281
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1282
                }
Bogdan Timofte authored 2 weeks ago
1283
            }
1284
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1285
        }
1286
    }
Bogdan Timofte authored a week ago
1287

            
1288
    fileprivate func discontinuityMarkers(
1289
        points: [Measurements.Measurement.Point],
1290
        context: ChartContext
1291
    ) -> some View {
1292
        GeometryReader { geometry in
1293
            Path { path in
1294
                for point in points where point.isDiscontinuity {
1295
                    let markerX = context.placeInRect(
1296
                        point: CGPoint(
1297
                            x: point.timestamp.timeIntervalSince1970,
1298
                            y: context.origin.y
1299
                        )
1300
                    ).x * geometry.size.width
1301
                    path.move(to: CGPoint(x: markerX, y: 0))
1302
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1303
                }
1304
            }
1305
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1306
        }
1307
    }
Bogdan Timofte authored 2 weeks ago
1308

            
1309
}
1310

            
1311
struct Chart : View {
1312

            
Bogdan Timofte authored 2 weeks ago
1313
    let points: [Measurements.Measurement.Point]
1314
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1315
    var areaChart: Bool = false
1316
    var strokeColor: Color = .black
1317

            
1318
    var body : some View {
1319
        GeometryReader { geometry in
1320
            if self.areaChart {
1321
                self.path( geometry: geometry )
1322
                    .fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9)))
1323
            } else {
1324
                self.path( geometry: geometry )
1325
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1326
            }
1327
        }
1328
    }
1329

            
1330
    fileprivate func path(geometry: GeometryProxy) -> Path {
1331
        return Path { path in
Bogdan Timofte authored a week ago
1332
            var firstSample: Measurements.Measurement.Point?
1333
            var lastSample: Measurements.Measurement.Point?
1334
            var needsMove = true
1335

            
1336
            for point in points {
1337
                if point.isDiscontinuity {
1338
                    needsMove = true
1339
                    continue
1340
                }
1341

            
1342
                let item = context.placeInRect(point: point.point())
1343
                let renderedPoint = CGPoint(
1344
                    x: item.x * geometry.size.width,
1345
                    y: item.y * geometry.size.height
1346
                )
1347

            
1348
                if firstSample == nil {
1349
                    firstSample = point
1350
                }
1351
                lastSample = point
1352

            
1353
                if needsMove {
1354
                    path.move(to: renderedPoint)
1355
                    needsMove = false
1356
                } else {
1357
                    path.addLine(to: renderedPoint)
1358
                }
Bogdan Timofte authored 2 weeks ago
1359
            }
Bogdan Timofte authored a week ago
1360

            
1361
            if self.areaChart, let firstSample, let lastSample {
1362
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1363
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1364
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1365
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1366
                // MARK: Nu e nevoie. Fill inchide automat calea
1367
                // path.closeSubpath()
1368
            }
1369
        }
1370
    }
1371

            
1372
}