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

            
17
        var unit: String {
18
            switch self {
19
            case .power: return "W"
20
            case .voltage: return "V"
21
            case .current: return "A"
22
            }
23
        }
24

            
25
        var tint: Color {
26
            switch self {
27
            case .power: return .red
28
            case .voltage: return .green
29
            case .current: return .blue
30
            }
31
        }
32
    }
33

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

            
Bogdan Timofte authored 2 weeks ago
44
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 weeks ago
45
    private let minimumVoltageSpan = 0.5
46
    private let minimumCurrentSpan = 0.5
47
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored a week ago
48
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
49

            
50
    let compactLayout: Bool
51
    let availableSize: CGSize
Bogdan Timofte authored 2 weeks ago
52

            
53
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored a week ago
54
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
55
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 weeks ago
56
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 weeks ago
57

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

            
Bogdan Timofte authored a week ago
73
    init(
74
        compactLayout: Bool = false,
75
        availableSize: CGSize = .zero,
76
        timeRange: ClosedRange<Date>? = nil
77
    ) {
78
        self.compactLayout = compactLayout
79
        self.availableSize = availableSize
80
        self.timeRange = timeRange
81
    }
82

            
83
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 6 days ago
84
        if compactLayout {
85
            return 38
86
        }
87
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored a week ago
88
    }
89

            
90
    private var chartSectionSpacing: CGFloat {
91
        compactLayout ? 6 : 8
92
    }
93

            
94
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 6 days ago
95
        if compactLayout {
96
            return 24
97
        }
98
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored a week ago
99
    }
100

            
Bogdan Timofte authored 6 days ago
101
    private var isPortraitLayout: Bool {
102
        guard availableSize != .zero else { return verticalSizeClass != .compact }
103
        return availableSize.height >= availableSize.width
104
    }
105

            
Bogdan Timofte authored 6 days ago
106
    private var isIPhone: Bool {
107
        #if os(iOS)
108
        return UIDevice.current.userInterfaceIdiom == .phone
109
        #else
110
        return false
111
        #endif
112
    }
113

            
114
    private enum OriginControlsPlacement {
115
        case aboveXAxisLegend
116
        case overXAxisLegend
117
        case belowXAxisLegend
118
    }
119

            
120
    private var originControlsPlacement: OriginControlsPlacement {
121
        if isIPhone {
122
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
123
        }
124
        return .belowXAxisLegend
125
    }
126

            
Bogdan Timofte authored a week ago
127
    private var plotSectionHeight: CGFloat {
128
        if availableSize == .zero {
Bogdan Timofte authored 6 days ago
129
            return compactLayout ? 300 : 380
130
        }
131

            
132
        if isPortraitLayout {
133
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
134
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
135
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored a week ago
136
        }
137

            
138
        if compactLayout {
139
            return min(max(availableSize.height * 0.36, 240), 300)
140
        }
141

            
142
        return min(max(availableSize.height * 0.5, 300), 440)
143
    }
144

            
145
    private var stackedToolbarLayout: Bool {
146
        if availableSize.width > 0 {
147
            return availableSize.width < 640
148
        }
149

            
150
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
151
    }
152

            
153
    private var showsLabeledOriginControls: Bool {
154
        !compactLayout && !stackedToolbarLayout
155
    }
156

            
Bogdan Timofte authored 6 days ago
157
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 6 days ago
158
        #if os(iOS)
159
        if UIDevice.current.userInterfaceIdiom == .phone {
160
            return false
161
        }
162
        #endif
163

            
Bogdan Timofte authored 6 days ago
164
        if availableSize.width > 0 {
165
            return availableSize.width >= 900 || availableSize.height >= 700
166
        }
167
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
168
    }
169

            
170
    private var chartBaseFont: Font {
Bogdan Timofte authored 6 days ago
171
        if isIPhone && isPortraitLayout {
172
            return .caption
173
        }
174
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 6 days ago
175
    }
176

            
Bogdan Timofte authored 6 days ago
177
    private var usesCompactLandscapeOriginControls: Bool {
178
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
179
    }
180

            
Bogdan Timofte authored 2 weeks ago
181
    var body: some View {
Bogdan Timofte authored a week ago
182
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
183
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
184
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
185
        let primarySeries = displayedPrimarySeries(
186
            powerSeries: powerSeries,
187
            voltageSeries: voltageSeries,
188
            currentSeries: currentSeries
189
        )
190

            
Bogdan Timofte authored 2 weeks ago
191
        Group {
Bogdan Timofte authored 2 weeks ago
192
            if let primarySeries {
193
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
194
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
195

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

            
199
                        VStack(spacing: 6) {
200
                            HStack(spacing: chartSectionSpacing) {
201
                                primaryAxisView(
202
                                    height: plotHeight,
203
                                    powerSeries: powerSeries,
204
                                    voltageSeries: voltageSeries,
205
                                    currentSeries: currentSeries
206
                                )
207
                                .frame(width: axisColumnWidth, height: plotHeight)
208

            
209
                                ZStack {
210
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
211
                                        .fill(Color.primary.opacity(0.05))
212

            
213
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
214
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
215

            
216
                                    horizontalGuides(context: primarySeries.context)
217
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
218
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
219
                                    renderedChart(
220
                                        powerSeries: powerSeries,
221
                                        voltageSeries: voltageSeries,
222
                                        currentSeries: currentSeries
223
                                    )
Bogdan Timofte authored 2 weeks ago
224
                                }
Bogdan Timofte authored 2 weeks ago
225
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
226
                                .frame(maxWidth: .infinity)
227
                                .frame(height: plotHeight)
228

            
229
                                secondaryAxisView(
230
                                    height: plotHeight,
231
                                    powerSeries: powerSeries,
232
                                    voltageSeries: voltageSeries,
233
                                    currentSeries: currentSeries
234
                                )
235
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
236
                            }
Bogdan Timofte authored 6 days ago
237
                            .overlay(alignment: .bottom) {
Bogdan Timofte authored 6 days ago
238
                                if originControlsPlacement == .aboveXAxisLegend {
239
                                    scaleControlsPill(
240
                                        voltageSeries: voltageSeries,
241
                                        currentSeries: currentSeries
242
                                    )
243
                                    .padding(.bottom, compactLayout ? 6 : 10)
244
                                }
Bogdan Timofte authored 6 days ago
245
                            }
Bogdan Timofte authored 2 weeks ago
246

            
Bogdan Timofte authored 6 days ago
247
                            switch originControlsPlacement {
248
                            case .aboveXAxisLegend:
249
                                xAxisLabelsView(context: primarySeries.context)
250
                                    .frame(height: xAxisHeight)
251
                            case .overXAxisLegend:
252
                                xAxisLabelsView(context: primarySeries.context)
253
                                    .frame(height: xAxisHeight)
254
                                    .overlay(alignment: .center) {
255
                                        scaleControlsPill(
256
                                            voltageSeries: voltageSeries,
257
                                            currentSeries: currentSeries
258
                                        )
Bogdan Timofte authored 6 days ago
259
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 6 days ago
260
                                    }
261
                            case .belowXAxisLegend:
262
                                xAxisLabelsView(context: primarySeries.context)
263
                                    .frame(height: xAxisHeight)
264

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

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

            
Bogdan Timofte authored 6 days ago
299
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
300
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
301
        }
302
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
303
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
304
        .background(
305
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
306
                .fill(Color.primary.opacity(0.045))
307
        )
308
        .overlay(
309
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
310
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
311
        )
Bogdan Timofte authored a week ago
312

            
Bogdan Timofte authored 6 days ago
313
        return Group {
Bogdan Timofte authored a week ago
314
            if stackedToolbarLayout {
Bogdan Timofte authored 6 days ago
315
                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
316
                    controlsPanel
317
                    HStack {
318
                        Spacer(minLength: 0)
319
                        resetBufferButton(condensedLayout: condensedLayout)
320
                    }
Bogdan Timofte authored 2 weeks ago
321
                }
Bogdan Timofte authored a week ago
322
            } else {
Bogdan Timofte authored 6 days ago
323
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
324
                    controlsPanel
Bogdan Timofte authored a week ago
325
                    Spacer(minLength: 0)
326
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
327
                }
Bogdan Timofte authored a week ago
328
            }
329
        }
330
        .frame(maxWidth: .infinity, alignment: .leading)
331
    }
332

            
Bogdan Timofte authored 6 days ago
333
    private var shouldFloatScaleControlsOverChart: Bool {
334
        #if os(iOS)
335
        if availableSize.width > 0, availableSize.height > 0 {
336
            return availableSize.width > availableSize.height
337
        }
338
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
339
        #else
340
        return false
341
        #endif
342
    }
343

            
344
    private func scaleControlsPill(
345
        voltageSeries: SeriesData,
346
        currentSeries: SeriesData
347
    ) -> some View {
348
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 6 days ago
349
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 6 days ago
350
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
351
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 6 days ago
352

            
353
        return originControlsRow(
354
            voltageSeries: voltageSeries,
355
            currentSeries: currentSeries,
356
            condensedLayout: condensedLayout,
357
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
358
        )
Bogdan Timofte authored 6 days ago
359
        .padding(.horizontal, horizontalPadding)
360
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 6 days ago
361
        .background(
362
            Capsule(style: .continuous)
Bogdan Timofte authored 6 days ago
363
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 6 days ago
364
        )
365
        .overlay(
366
            Capsule(style: .continuous)
367
                .stroke(
Bogdan Timofte authored 6 days ago
368
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 6 days ago
369
                    lineWidth: 1
370
                )
371
        )
372
    }
373

            
Bogdan Timofte authored a week ago
374
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
375
        HStack(spacing: condensedLayout ? 6 : 8) {
376
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
377
                displayVoltage.toggle()
378
                if displayVoltage {
379
                    displayPower = false
380
                }
381
            }
382

            
383
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
384
                displayCurrent.toggle()
385
                if displayCurrent {
386
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
387
                }
Bogdan Timofte authored a week ago
388
            }
389

            
390
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
391
                displayPower.toggle()
392
                if displayPower {
393
                    displayCurrent = false
394
                    displayVoltage = false
395
                }
396
            }
397
        }
398
    }
399

            
400
    private func originControlsRow(
401
        voltageSeries: SeriesData,
402
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
403
        condensedLayout: Bool,
404
        showsLabel: Bool
Bogdan Timofte authored a week ago
405
    ) -> some View {
Bogdan Timofte authored 6 days ago
406
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
407
            if supportsSharedOrigin {
408
                symbolControlChip(
409
                    systemImage: "equal.circle",
410
                    enabled: true,
411
                    active: useSharedOrigin,
412
                    condensedLayout: condensedLayout,
413
                    showsLabel: showsLabel,
414
                    label: "Match Y Scale",
415
                    accessibilityLabel: "Match Y scale"
416
                ) {
417
                    toggleSharedOrigin(
418
                        voltageSeries: voltageSeries,
419
                        currentSeries: currentSeries
420
                    )
421
                }
Bogdan Timofte authored a week ago
422
            }
423

            
424
            symbolControlChip(
425
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
426
                enabled: true,
427
                active: pinOrigin,
428
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
429
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
430
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
431
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
432
            ) {
433
                togglePinnedOrigin(
434
                    voltageSeries: voltageSeries,
435
                    currentSeries: currentSeries
436
                )
437
            }
438

            
Bogdan Timofte authored 6 days ago
439
            if !pinnedOriginIsZero {
440
                symbolControlChip(
441
                    systemImage: "0.circle",
442
                    enabled: true,
443
                    active: false,
444
                    condensedLayout: condensedLayout,
445
                    showsLabel: showsLabel,
446
                    label: "Origin 0",
447
                    accessibilityLabel: "Set origin to zero"
448
                ) {
449
                    setVisibleOriginsToZero()
450
                }
Bogdan Timofte authored a week ago
451
            }
Bogdan Timofte authored 6 days ago
452

            
Bogdan Timofte authored a week ago
453
        }
454
    }
455

            
456
    private func seriesToggleButton(
457
        title: String,
458
        isOn: Bool,
459
        condensedLayout: Bool,
460
        action: @escaping () -> Void
461
    ) -> some View {
462
        Button(action: action) {
463
            Text(title)
Bogdan Timofte authored 6 days ago
464
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
465
                .lineLimit(1)
466
                .minimumScaleFactor(0.82)
467
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 6 days ago
468
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
469
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
470
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored a week ago
471
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
472
                .background(
473
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
474
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
475
                )
476
                .overlay(
477
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
478
                        .stroke(Color.blue, lineWidth: 1.5)
479
                )
480
        }
481
        .buttonStyle(.plain)
482
    }
483

            
484
    private func symbolControlChip(
485
        systemImage: String,
486
        enabled: Bool,
487
        active: Bool,
488
        condensedLayout: Bool,
489
        showsLabel: Bool,
490
        label: String,
491
        accessibilityLabel: String,
492
        action: @escaping () -> Void
493
    ) -> some View {
494
        Button(action: {
495
            action()
496
        }) {
497
            Group {
498
                if showsLabel {
499
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 6 days ago
500
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 6 days ago
501
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
502
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored a week ago
503
                } else {
504
                    Image(systemName: systemImage)
Bogdan Timofte authored 6 days ago
505
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 6 days ago
506
                        .frame(
Bogdan Timofte authored 6 days ago
507
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
508
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 6 days ago
509
                        )
Bogdan Timofte authored a week ago
510
                }
511
            }
512
                .background(
513
                    Capsule(style: .continuous)
514
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
515
                )
516
        }
517
        .buttonStyle(.plain)
518
        .foregroundColor(enabled ? .primary : .secondary)
519
        .opacity(enabled ? 1 : 0.55)
520
        .accessibilityLabel(accessibilityLabel)
521
    }
522

            
523
    private func resetBufferButton(condensedLayout: Bool) -> some View {
524
        Button(action: {
525
            showResetConfirmation = true
526
        }) {
527
            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
Bogdan Timofte authored 6 days ago
528
                .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
529
                .padding(.horizontal, condensedLayout ? 14 : 16)
Bogdan Timofte authored 6 days ago
530
                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
Bogdan Timofte authored a week ago
531
        }
532
        .buttonStyle(.plain)
533
        .foregroundColor(.white)
534
        .background(
535
            Capsule(style: .continuous)
536
                .fill(Color.red.opacity(0.8))
537
        )
538
        .fixedSize(horizontal: true, vertical: false)
539
        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
540
            Button("Reset series", role: .destructive) {
541
                measurements.resetSeries()
542
            }
543
            Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 weeks ago
544
        }
545
    }
546

            
Bogdan Timofte authored 6 days ago
547
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
548
        if isLargeDisplay {
549
            return .body.weight(.semibold)
550
        }
551
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
552
    }
553

            
554
    private func controlChipFont(condensedLayout: Bool) -> Font {
555
        if isLargeDisplay {
556
            return .callout.weight(.semibold)
557
        }
558
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
559
    }
560

            
Bogdan Timofte authored 2 weeks ago
561
    @ViewBuilder
562
    private func primaryAxisView(
563
        height: CGFloat,
Bogdan Timofte authored a week ago
564
        powerSeries: SeriesData,
565
        voltageSeries: SeriesData,
566
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
567
    ) -> some View {
568
        if displayPower {
569
            yAxisLabelsView(
570
                height: height,
571
                context: powerSeries.context,
Bogdan Timofte authored a week ago
572
                seriesKind: .power,
573
                measurementUnit: powerSeries.kind.unit,
574
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
575
            )
576
        } else if displayVoltage {
577
            yAxisLabelsView(
578
                height: height,
579
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
580
                seriesKind: .voltage,
581
                measurementUnit: voltageSeries.kind.unit,
582
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
583
            )
584
        } else if displayCurrent {
585
            yAxisLabelsView(
586
                height: height,
587
                context: currentSeries.context,
Bogdan Timofte authored a week ago
588
                seriesKind: .current,
589
                measurementUnit: currentSeries.kind.unit,
590
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
591
            )
592
        }
593
    }
594

            
595
    @ViewBuilder
596
    private func renderedChart(
Bogdan Timofte authored a week ago
597
        powerSeries: SeriesData,
598
        voltageSeries: SeriesData,
599
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
600
    ) -> some View {
601
        if self.displayPower {
602
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
603
                .opacity(0.72)
604
        } else {
605
            if self.displayVoltage {
606
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
607
                    .opacity(0.78)
608
            }
609
            if self.displayCurrent {
610
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
611
                    .opacity(0.78)
612
            }
613
        }
614
    }
615

            
616
    @ViewBuilder
617
    private func secondaryAxisView(
618
        height: CGFloat,
Bogdan Timofte authored a week ago
619
        powerSeries: SeriesData,
620
        voltageSeries: SeriesData,
621
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
622
    ) -> some View {
623
        if displayVoltage && displayCurrent {
624
            yAxisLabelsView(
625
                height: height,
626
                context: currentSeries.context,
Bogdan Timofte authored a week ago
627
                seriesKind: .current,
628
                measurementUnit: currentSeries.kind.unit,
629
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
630
            )
631
        } else {
632
            primaryAxisView(
633
                height: height,
634
                powerSeries: powerSeries,
635
                voltageSeries: voltageSeries,
636
                currentSeries: currentSeries
637
            )
Bogdan Timofte authored 2 weeks ago
638
        }
639
    }
Bogdan Timofte authored 2 weeks ago
640

            
641
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
642
        powerSeries: SeriesData,
643
        voltageSeries: SeriesData,
644
        currentSeries: SeriesData
645
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
646
        if displayPower {
Bogdan Timofte authored a week ago
647
            return powerSeries
Bogdan Timofte authored 2 weeks ago
648
        }
649
        if displayVoltage {
Bogdan Timofte authored a week ago
650
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
651
        }
652
        if displayCurrent {
Bogdan Timofte authored a week ago
653
            return currentSeries
Bogdan Timofte authored 2 weeks ago
654
        }
655
        return nil
656
    }
657

            
658
    private func series(
659
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
660
        kind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
661
        minimumYSpan: Double
Bogdan Timofte authored a week ago
662
    ) -> SeriesData {
Bogdan Timofte authored 2 weeks ago
663
        let points = measurement.points.filter { point in
664
            guard let timeRange else { return true }
665
            return timeRange.contains(point.timestamp)
666
        }
Bogdan Timofte authored a week ago
667
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
668
        let context = ChartContext()
Bogdan Timofte authored a week ago
669

            
670
        let autoBounds = automaticYBounds(
671
            for: samplePoints,
672
            minimumYSpan: minimumYSpan
673
        )
674
        let xBounds = xBounds(for: samplePoints)
675
        let lowerBound = resolvedLowerBound(
676
            for: kind,
677
            autoLowerBound: autoBounds.lowerBound
678
        )
679
        let upperBound = resolvedUpperBound(
680
            for: kind,
681
            lowerBound: lowerBound,
682
            autoUpperBound: autoBounds.upperBound,
683
            maximumSampleValue: samplePoints.map(\.value).max(),
684
            minimumYSpan: minimumYSpan
685
        )
686

            
687
        context.setBounds(
688
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
689
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
690
            yMin: CGFloat(lowerBound),
691
            yMax: CGFloat(upperBound)
692
        )
693

            
694
        return SeriesData(
695
            kind: kind,
696
            points: points,
697
            samplePoints: samplePoints,
698
            context: context,
699
            autoLowerBound: autoBounds.lowerBound,
700
            autoUpperBound: autoBounds.upperBound,
701
            maximumSampleValue: samplePoints.map(\.value).max()
702
        )
703
    }
704

            
705
    private var supportsSharedOrigin: Bool {
706
        displayVoltage && displayCurrent && !displayPower
707
    }
708

            
Bogdan Timofte authored 6 days ago
709
    private var minimumSharedScaleSpan: Double {
710
        max(minimumVoltageSpan, minimumCurrentSpan)
711
    }
712

            
Bogdan Timofte authored a week ago
713
    private var pinnedOriginIsZero: Bool {
714
        if useSharedOrigin && supportsSharedOrigin {
715
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
716
        }
Bogdan Timofte authored a week ago
717

            
718
        if displayPower {
719
            return pinOrigin && powerAxisOrigin == 0
720
        }
721

            
722
        let visibleOrigins = [
723
            displayVoltage ? voltageAxisOrigin : nil,
724
            displayCurrent ? currentAxisOrigin : nil
725
        ]
726
        .compactMap { $0 }
727

            
728
        guard !visibleOrigins.isEmpty else { return false }
729
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
730
    }
731

            
732
    private func toggleSharedOrigin(
733
        voltageSeries: SeriesData,
734
        currentSeries: SeriesData
735
    ) {
736
        guard supportsSharedOrigin else { return }
737

            
738
        if useSharedOrigin {
739
            useSharedOrigin = false
740
            return
741
        }
742

            
743
        captureCurrentOrigins(
744
            voltageSeries: voltageSeries,
745
            currentSeries: currentSeries
746
        )
747
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
748
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
749
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
750
        useSharedOrigin = true
751
        pinOrigin = true
752
    }
753

            
754
    private func togglePinnedOrigin(
755
        voltageSeries: SeriesData,
756
        currentSeries: SeriesData
757
    ) {
758
        if pinOrigin {
759
            pinOrigin = false
760
            return
761
        }
762

            
763
        captureCurrentOrigins(
764
            voltageSeries: voltageSeries,
765
            currentSeries: currentSeries
766
        )
767
        pinOrigin = true
768
    }
769

            
770
    private func setVisibleOriginsToZero() {
771
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
772
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
773
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
774
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
775
            voltageAxisOrigin = 0
776
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
777
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
778
        } else {
779
            if displayPower {
780
                powerAxisOrigin = 0
781
            }
782
            if displayVoltage {
783
                voltageAxisOrigin = 0
784
            }
785
            if displayCurrent {
786
                currentAxisOrigin = 0
787
            }
788
        }
789

            
790
        pinOrigin = true
791
    }
792

            
793
    private func captureCurrentOrigins(
794
        voltageSeries: SeriesData,
795
        currentSeries: SeriesData
796
    ) {
797
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
798
        voltageAxisOrigin = voltageSeries.autoLowerBound
799
        currentAxisOrigin = currentSeries.autoLowerBound
800
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
801
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
802
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
803
    }
804

            
805
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
806
        switch kind {
807
        case .power:
808
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
809
        case .voltage:
810
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
811
                return sharedAxisOrigin
812
            }
813
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
814
        case .current:
815
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
816
                return sharedAxisOrigin
817
            }
818
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
819
        }
820
    }
821

            
822
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
823
        measurement.points.filter { point in
824
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
825
        }
826
    }
827

            
828
    private func xBounds(
829
        for samplePoints: [Measurements.Measurement.Point]
830
    ) -> ClosedRange<Date> {
831
        if let timeRange {
832
            return timeRange
833
        }
834

            
835
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
836
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
837

            
838
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
839
            return lowerBound...upperBound
840
        }
841

            
842
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
843
    }
844

            
845
    private func automaticYBounds(
846
        for samplePoints: [Measurements.Measurement.Point],
847
        minimumYSpan: Double
848
    ) -> (lowerBound: Double, upperBound: Double) {
849
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
850

            
851
        guard
852
            let minimumSampleValue = samplePoints.map(\.value).min(),
853
            let maximumSampleValue = samplePoints.map(\.value).max()
854
        else {
855
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
856
        }
Bogdan Timofte authored a week ago
857

            
858
        var lowerBound = minimumSampleValue
859
        var upperBound = maximumSampleValue
860
        let currentSpan = upperBound - lowerBound
861

            
862
        if currentSpan < minimumYSpan {
863
            let expansion = (minimumYSpan - currentSpan) / 2
864
            lowerBound -= expansion
865
            upperBound += expansion
866
        }
867

            
868
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
869
            let shift = -negativeAllowance - lowerBound
870
            lowerBound += shift
871
            upperBound += shift
872
        }
873

            
874
        let snappedLowerBound = snappedOriginValue(lowerBound)
875
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
876
        return (snappedLowerBound, resolvedUpperBound)
877
    }
878

            
879
    private func resolvedLowerBound(
880
        for kind: SeriesKind,
881
        autoLowerBound: Double
882
    ) -> Double {
883
        guard pinOrigin else { return autoLowerBound }
884

            
885
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
886
            return sharedAxisOrigin
887
        }
888

            
889
        switch kind {
890
        case .power:
891
            return powerAxisOrigin
892
        case .voltage:
893
            return voltageAxisOrigin
894
        case .current:
895
            return currentAxisOrigin
896
        }
897
    }
898

            
899
    private func resolvedUpperBound(
900
        for kind: SeriesKind,
901
        lowerBound: Double,
902
        autoUpperBound: Double,
903
        maximumSampleValue: Double?,
904
        minimumYSpan: Double
905
    ) -> Double {
906
        guard pinOrigin else {
907
            return autoUpperBound
908
        }
909

            
Bogdan Timofte authored 6 days ago
910
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
911
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
912
        }
913

            
Bogdan Timofte authored a week ago
914
        return max(
915
            maximumSampleValue ?? lowerBound,
916
            lowerBound + minimumYSpan,
917
            autoUpperBound
918
        )
919
    }
920

            
Bogdan Timofte authored 6 days ago
921
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
922
        let baseline = displayedLowerBoundForSeries(kind)
923
        let proposedOrigin = snappedOriginValue(baseline + delta)
924

            
925
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
926
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
927
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
928
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
929
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
930
        } else {
931
            switch kind {
932
            case .power:
933
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
934
            case .voltage:
935
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
936
            case .current:
937
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
938
            }
939
        }
940

            
941
        pinOrigin = true
942
    }
943

            
Bogdan Timofte authored 6 days ago
944
    private func clearOriginOffset(for kind: SeriesKind) {
945
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
946
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
947
            sharedAxisOrigin = 0
948
            sharedAxisUpperBound = currentSpan
949
            ensureSharedScaleSpan()
950
            voltageAxisOrigin = 0
951
            currentAxisOrigin = 0
952
        } else {
953
            switch kind {
954
            case .power:
955
                powerAxisOrigin = 0
956
            case .voltage:
957
                voltageAxisOrigin = 0
958
            case .current:
959
                currentAxisOrigin = 0
960
            }
961
        }
962

            
963
        pinOrigin = true
964
    }
965

            
966
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
967
        guard totalHeight > 1 else { return }
968

            
969
        let normalized = max(0, min(1, locationY / totalHeight))
970
        if normalized < (1.0 / 3.0) {
971
            applyOriginDelta(-1, kind: kind)
972
        } else if normalized < (2.0 / 3.0) {
973
            clearOriginOffset(for: kind)
974
        } else {
975
            applyOriginDelta(1, kind: kind)
976
        }
977
    }
978

            
Bogdan Timofte authored a week ago
979
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
980
        switch kind {
981
        case .power:
982
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
983
        case .voltage:
984
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
985
        case .current:
986
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
987
        }
988
    }
989

            
990
    private func maximumVisibleSharedOrigin() -> Double {
991
        min(
992
            maximumVisibleOrigin(for: .voltage),
993
            maximumVisibleOrigin(for: .current)
994
        )
995
    }
996

            
Bogdan Timofte authored 6 days ago
997
    private func ensureSharedScaleSpan() {
998
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
999
    }
1000

            
Bogdan Timofte authored a week ago
1001
    private func snappedOriginValue(_ value: Double) -> Double {
1002
        if value >= 0 {
1003
            return value.rounded(.down)
1004
        }
1005

            
1006
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
1007
    }
Bogdan Timofte authored 2 weeks ago
1008

            
1009
    private func yGuidePosition(
1010
        for labelIndex: Int,
1011
        context: ChartContext,
1012
        height: CGFloat
1013
    ) -> CGFloat {
1014
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1015
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1016
        return context.placeInRect(point: anchorPoint).y * height
1017
    }
1018

            
1019
    private func xGuidePosition(
1020
        for labelIndex: Int,
1021
        context: ChartContext,
1022
        width: CGFloat
1023
    ) -> CGFloat {
1024
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1025
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1026
        return context.placeInRect(point: anchorPoint).x * width
1027
    }
Bogdan Timofte authored 2 weeks ago
1028

            
1029
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
1030
    fileprivate func xAxisLabelsView(
1031
        context: ChartContext
1032
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
1033
        var timeFormat: String?
1034
        switch context.size.width {
1035
        case 0..<3600: timeFormat = "HH:mm:ss"
1036
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
1037
        default: timeFormat = "E HH:mm"
1038
        }
1039
        let labels = (1...xLabels).map {
1040
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
1041
        }
Bogdan Timofte authored 6 days ago
1042
        let axisLabelFont: Font = {
1043
            if isIPhone && isPortraitLayout {
1044
                return .caption2.weight(.semibold)
1045
            }
1046
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1047
        }()
Bogdan Timofte authored 2 weeks ago
1048

            
1049
        return HStack(spacing: chartSectionSpacing) {
1050
            Color.clear
1051
                .frame(width: axisColumnWidth)
1052

            
1053
            GeometryReader { geometry in
1054
                let labelWidth = max(
1055
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1056
                    1
1057
                )
1058

            
1059
                ZStack(alignment: .topLeading) {
1060
                    Path { path in
1061
                        for labelIndex in 1...self.xLabels {
1062
                            let x = xGuidePosition(
1063
                                for: labelIndex,
1064
                                context: context,
1065
                                width: geometry.size.width
1066
                            )
1067
                            path.move(to: CGPoint(x: x, y: 0))
1068
                            path.addLine(to: CGPoint(x: x, y: 6))
1069
                        }
1070
                    }
1071
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1072

            
1073
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1074
                        let labelIndex = item.offset + 1
1075
                        let centerX = xGuidePosition(
1076
                            for: labelIndex,
1077
                            context: context,
1078
                            width: geometry.size.width
1079
                        )
1080

            
1081
                        Text(item.element)
Bogdan Timofte authored 6 days ago
1082
                            .font(axisLabelFont)
Bogdan Timofte authored 2 weeks ago
1083
                            .monospacedDigit()
1084
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1085
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1086
                            .frame(width: labelWidth)
1087
                            .position(
1088
                                x: centerX,
1089
                                y: geometry.size.height * 0.7
1090
                            )
Bogdan Timofte authored 2 weeks ago
1091
                    }
1092
                }
Bogdan Timofte authored 2 weeks ago
1093
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1094
            }
Bogdan Timofte authored 2 weeks ago
1095

            
1096
            Color.clear
1097
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1098
        }
1099
    }
1100

            
Bogdan Timofte authored a week ago
1101
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1102
        height: CGFloat,
1103
        context: ChartContext,
Bogdan Timofte authored a week ago
1104
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1105
        measurementUnit: String,
1106
        tint: Color
1107
    ) -> some View {
Bogdan Timofte authored 6 days ago
1108
        let yAxisFont: Font = {
1109
            if isIPhone && isPortraitLayout {
1110
                return .caption2.weight(.semibold)
1111
            }
1112
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1113
        }()
1114

            
1115
        let unitFont: Font = {
1116
            if isIPhone && isPortraitLayout {
1117
                return .caption2.weight(.bold)
1118
            }
1119
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1120
        }()
1121

            
1122
        return GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1123
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1124
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1125
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1126

            
Bogdan Timofte authored 2 weeks ago
1127
            ZStack(alignment: .top) {
1128
                ForEach(0..<yLabels, id: \.self) { row in
1129
                    let labelIndex = yLabels - row
1130

            
1131
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1132
                        .font(yAxisFont)
Bogdan Timofte authored 2 weeks ago
1133
                        .monospacedDigit()
1134
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1135
                        .minimumScaleFactor(0.8)
1136
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1137
                        .position(
1138
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1139
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1140
                                for: labelIndex,
1141
                                context: context,
Bogdan Timofte authored 6 days ago
1142
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1143
                            )
1144
                        )
Bogdan Timofte authored 2 weeks ago
1145
                }
Bogdan Timofte authored 2 weeks ago
1146

            
Bogdan Timofte authored 2 weeks ago
1147
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1148
                    .font(unitFont)
Bogdan Timofte authored 2 weeks ago
1149
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1150
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1151
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1152
                    .background(
1153
                        Capsule(style: .continuous)
1154
                            .fill(tint.opacity(0.14))
1155
                    )
Bogdan Timofte authored 6 days ago
1156
                    .padding(.top, 8)
1157

            
Bogdan Timofte authored 2 weeks ago
1158
            }
1159
        }
Bogdan Timofte authored 2 weeks ago
1160
        .frame(height: height)
1161
        .background(
1162
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1163
                .fill(tint.opacity(0.12))
1164
        )
1165
        .overlay(
1166
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1167
                .stroke(tint.opacity(0.20), lineWidth: 1)
1168
        )
Bogdan Timofte authored a week ago
1169
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1170
        .gesture(
Bogdan Timofte authored 6 days ago
1171
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1172
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1173
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1174
                }
1175
        )
Bogdan Timofte authored 2 weeks ago
1176
    }
1177

            
Bogdan Timofte authored 2 weeks ago
1178
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1179
        GeometryReader { geometry in
1180
            Path { path in
Bogdan Timofte authored 2 weeks ago
1181
                for labelIndex in 1...self.yLabels {
1182
                    let y = yGuidePosition(
1183
                        for: labelIndex,
1184
                        context: context,
1185
                        height: geometry.size.height
1186
                    )
1187
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1188
                }
Bogdan Timofte authored 2 weeks ago
1189
            }
1190
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1191
        }
1192
    }
1193

            
Bogdan Timofte authored 2 weeks ago
1194
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1195
        GeometryReader { geometry in
1196
            Path { path in
1197

            
Bogdan Timofte authored 2 weeks ago
1198
                for labelIndex in 2..<self.xLabels {
1199
                    let x = xGuidePosition(
1200
                        for: labelIndex,
1201
                        context: context,
1202
                        width: geometry.size.width
1203
                    )
1204
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1205
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1206
                }
Bogdan Timofte authored 2 weeks ago
1207
            }
1208
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1209
        }
1210
    }
Bogdan Timofte authored a week ago
1211

            
1212
    fileprivate func discontinuityMarkers(
1213
        points: [Measurements.Measurement.Point],
1214
        context: ChartContext
1215
    ) -> some View {
1216
        GeometryReader { geometry in
1217
            Path { path in
1218
                for point in points where point.isDiscontinuity {
1219
                    let markerX = context.placeInRect(
1220
                        point: CGPoint(
1221
                            x: point.timestamp.timeIntervalSince1970,
1222
                            y: context.origin.y
1223
                        )
1224
                    ).x * geometry.size.width
1225
                    path.move(to: CGPoint(x: markerX, y: 0))
1226
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1227
                }
1228
            }
1229
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1230
        }
1231
    }
Bogdan Timofte authored 2 weeks ago
1232

            
1233
}
1234

            
1235
struct Chart : View {
1236

            
Bogdan Timofte authored 2 weeks ago
1237
    let points: [Measurements.Measurement.Point]
1238
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1239
    var areaChart: Bool = false
1240
    var strokeColor: Color = .black
1241

            
1242
    var body : some View {
1243
        GeometryReader { geometry in
1244
            if self.areaChart {
1245
                self.path( geometry: geometry )
1246
                    .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)))
1247
            } else {
1248
                self.path( geometry: geometry )
1249
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1250
            }
1251
        }
1252
    }
1253

            
1254
    fileprivate func path(geometry: GeometryProxy) -> Path {
1255
        return Path { path in
Bogdan Timofte authored a week ago
1256
            var firstSample: Measurements.Measurement.Point?
1257
            var lastSample: Measurements.Measurement.Point?
1258
            var needsMove = true
1259

            
1260
            for point in points {
1261
                if point.isDiscontinuity {
1262
                    needsMove = true
1263
                    continue
1264
                }
1265

            
1266
                let item = context.placeInRect(point: point.point())
1267
                let renderedPoint = CGPoint(
1268
                    x: item.x * geometry.size.width,
1269
                    y: item.y * geometry.size.height
1270
                )
1271

            
1272
                if firstSample == nil {
1273
                    firstSample = point
1274
                }
1275
                lastSample = point
1276

            
1277
                if needsMove {
1278
                    path.move(to: renderedPoint)
1279
                    needsMove = false
1280
                } else {
1281
                    path.addLine(to: renderedPoint)
1282
                }
Bogdan Timofte authored 2 weeks ago
1283
            }
Bogdan Timofte authored a week ago
1284

            
1285
            if self.areaChart, let firstSample, let lastSample {
1286
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1287
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1288
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1289
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1290
                // MARK: Nu e nevoie. Fill inchide automat calea
1291
                // path.closeSubpath()
1292
            }
1293
        }
1294
    }
1295

            
1296
}