USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
1069 lines | 38.388kb
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 axisSwipeThreshold: CGFloat = 12
49
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
50

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

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

            
59
    @State var displayVoltage: Bool = false
60
    @State var displayCurrent: Bool = false
61
    @State var displayPower: Bool = true
Bogdan Timofte authored a week ago
62
    @State private var showResetConfirmation: Bool = false
63
    @State private var chartNow: Date = Date()
64
    @State private var pinOrigin: Bool = false
65
    @State private var useSharedOrigin: Bool = false
66
    @State private var sharedAxisOrigin: Double = 0
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 {
84
        compactLayout ? 38 : 46
85
    }
86

            
87
    private var chartSectionSpacing: CGFloat {
88
        compactLayout ? 6 : 8
89
    }
90

            
91
    private var xAxisHeight: CGFloat {
92
        compactLayout ? 24 : 28
93
    }
94

            
95
    private var plotSectionHeight: CGFloat {
96
        if availableSize == .zero {
97
            return compactLayout ? 260 : 340
98
        }
99

            
100
        if compactLayout {
101
            return min(max(availableSize.height * 0.36, 240), 300)
102
        }
103

            
104
        return min(max(availableSize.height * 0.5, 300), 440)
105
    }
106

            
107
    private var stackedToolbarLayout: Bool {
108
        if availableSize.width > 0 {
109
            return availableSize.width < 640
110
        }
111

            
112
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
113
    }
114

            
115
    private var showsLabeledOriginControls: Bool {
116
        !compactLayout && !stackedToolbarLayout
117
    }
118

            
Bogdan Timofte authored 2 weeks ago
119
    var body: some View {
Bogdan Timofte authored a week ago
120
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
121
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
122
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
123
        let primarySeries = displayedPrimarySeries(
124
            powerSeries: powerSeries,
125
            voltageSeries: voltageSeries,
126
            currentSeries: currentSeries
127
        )
128

            
Bogdan Timofte authored 2 weeks ago
129
        Group {
Bogdan Timofte authored 2 weeks ago
130
            if let primarySeries {
131
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a week ago
132
                    chartToggleBar(
133
                        voltageSeries: voltageSeries,
134
                        currentSeries: currentSeries
135
                    )
Bogdan Timofte authored 2 weeks ago
136

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

            
140
                        VStack(spacing: 6) {
141
                            HStack(spacing: chartSectionSpacing) {
142
                                primaryAxisView(
143
                                    height: plotHeight,
144
                                    powerSeries: powerSeries,
145
                                    voltageSeries: voltageSeries,
146
                                    currentSeries: currentSeries
147
                                )
148
                                .frame(width: axisColumnWidth, height: plotHeight)
149

            
150
                                ZStack {
151
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
152
                                        .fill(Color.primary.opacity(0.05))
153

            
154
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
155
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
156

            
157
                                    horizontalGuides(context: primarySeries.context)
158
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
159
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
160
                                    renderedChart(
161
                                        powerSeries: powerSeries,
162
                                        voltageSeries: voltageSeries,
163
                                        currentSeries: currentSeries
164
                                    )
Bogdan Timofte authored 2 weeks ago
165
                                }
Bogdan Timofte authored 2 weeks ago
166
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
167
                                .frame(maxWidth: .infinity)
168
                                .frame(height: plotHeight)
169

            
170
                                secondaryAxisView(
171
                                    height: plotHeight,
172
                                    powerSeries: powerSeries,
173
                                    voltageSeries: voltageSeries,
174
                                    currentSeries: currentSeries
175
                                )
176
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
177
                            }
Bogdan Timofte authored 2 weeks ago
178

            
179
                            xAxisLabelsView(context: primarySeries.context)
180
                            .frame(height: xAxisHeight)
Bogdan Timofte authored 2 weeks ago
181
                        }
Bogdan Timofte authored 2 weeks ago
182
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
183
                    }
Bogdan Timofte authored a week ago
184
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 weeks ago
185
                }
Bogdan Timofte authored 2 weeks ago
186
            } else {
187
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored a week ago
188
                    chartToggleBar(
189
                        voltageSeries: voltageSeries,
190
                        currentSeries: currentSeries
191
                    )
192
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 weeks ago
193
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
194
                }
195
            }
Bogdan Timofte authored 2 weeks ago
196
        }
197
        .font(.footnote)
198
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a week ago
199
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
200
            guard timeRange == nil else { return }
201
            chartNow = now
202
        }
Bogdan Timofte authored 2 weeks ago
203
    }
204

            
Bogdan Timofte authored a week ago
205
    private func chartToggleBar(
206
        voltageSeries: SeriesData,
207
        currentSeries: SeriesData
208
    ) -> some View {
209
        let condensedLayout = compactLayout || verticalSizeClass == .compact
210

            
211
        return VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
212
            seriesToggleRow(condensedLayout: condensedLayout)
213

            
214
            if stackedToolbarLayout {
215
                HStack(alignment: .center, spacing: 10) {
216
                    originControlsRow(
217
                        voltageSeries: voltageSeries,
218
                        currentSeries: currentSeries,
219
                        condensedLayout: condensedLayout
220
                    )
221

            
222
                    Spacer(minLength: 0)
223

            
224
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
225
                }
Bogdan Timofte authored a week ago
226
            } else {
227
                HStack(alignment: .center, spacing: 16) {
228
                    originControlsRow(
229
                        voltageSeries: voltageSeries,
230
                        currentSeries: currentSeries,
231
                        condensedLayout: condensedLayout
232
                    )
233

            
234
                    Spacer(minLength: 0)
Bogdan Timofte authored 2 weeks ago
235

            
Bogdan Timofte authored a week ago
236
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
237
                }
Bogdan Timofte authored a week ago
238
            }
239
        }
240
        .frame(maxWidth: .infinity, alignment: .leading)
241
    }
242

            
243
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
244
        HStack(spacing: condensedLayout ? 6 : 8) {
245
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
246
                displayVoltage.toggle()
247
                if displayVoltage {
248
                    displayPower = false
249
                }
250
            }
251

            
252
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
253
                displayCurrent.toggle()
254
                if displayCurrent {
255
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
256
                }
Bogdan Timofte authored a week ago
257
            }
258

            
259
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
260
                displayPower.toggle()
261
                if displayPower {
262
                    displayCurrent = false
263
                    displayVoltage = false
264
                }
265
            }
266
        }
267
    }
268

            
269
    private func originControlsRow(
270
        voltageSeries: SeriesData,
271
        currentSeries: SeriesData,
272
        condensedLayout: Bool
273
    ) -> some View {
274
        HStack(spacing: condensedLayout ? 8 : 10) {
275
            symbolControlChip(
276
                systemImage: "equal.circle",
277
                enabled: supportsSharedOrigin,
278
                active: useSharedOrigin && supportsSharedOrigin,
279
                condensedLayout: condensedLayout,
280
                showsLabel: showsLabeledOriginControls,
281
                label: "Match Y Origin",
282
                accessibilityLabel: "Match Y origin"
283
            ) {
284
                toggleSharedOrigin(
285
                    voltageSeries: voltageSeries,
286
                    currentSeries: currentSeries
287
                )
288
            }
289

            
290
            symbolControlChip(
291
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
292
                enabled: true,
293
                active: pinOrigin,
294
                condensedLayout: condensedLayout,
295
                showsLabel: showsLabeledOriginControls,
296
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
297
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
298
            ) {
299
                togglePinnedOrigin(
300
                    voltageSeries: voltageSeries,
301
                    currentSeries: currentSeries
302
                )
303
            }
304

            
305
            symbolControlChip(
306
                systemImage: "0.circle",
307
                enabled: true,
308
                active: pinnedOriginIsZero,
309
                condensedLayout: condensedLayout,
310
                showsLabel: showsLabeledOriginControls,
311
                label: "Origin 0",
312
                accessibilityLabel: "Set origin to zero"
313
            ) {
314
                setVisibleOriginsToZero()
315
            }
316
        }
317
    }
318

            
319
    private func seriesToggleButton(
320
        title: String,
321
        isOn: Bool,
322
        condensedLayout: Bool,
323
        action: @escaping () -> Void
324
    ) -> some View {
325
        Button(action: action) {
326
            Text(title)
327
                .font((condensedLayout ? Font.callout : .body).weight(.semibold))
328
                .lineLimit(1)
329
                .minimumScaleFactor(0.82)
330
                .foregroundColor(isOn ? .white : .blue)
331
                .padding(.horizontal, condensedLayout ? 10 : 12)
332
                .padding(.vertical, condensedLayout ? 7 : 8)
333
                .frame(minWidth: condensedLayout ? 0 : 84)
334
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
335
                .background(
336
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
337
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
338
                )
339
                .overlay(
340
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
341
                        .stroke(Color.blue, lineWidth: 1.5)
342
                )
343
        }
344
        .buttonStyle(.plain)
345
    }
346

            
347
    private func symbolControlChip(
348
        systemImage: String,
349
        enabled: Bool,
350
        active: Bool,
351
        condensedLayout: Bool,
352
        showsLabel: Bool,
353
        label: String,
354
        accessibilityLabel: String,
355
        action: @escaping () -> Void
356
    ) -> some View {
357
        Button(action: {
358
            action()
359
        }) {
360
            Group {
361
                if showsLabel {
362
                    Label(label, systemImage: systemImage)
363
                        .font((condensedLayout ? Font.callout : .footnote).weight(.semibold))
364
                        .padding(.horizontal, condensedLayout ? 10 : 12)
365
                        .padding(.vertical, condensedLayout ? 7 : 8)
366
                } else {
367
                    Image(systemName: systemImage)
368
                        .font(.system(size: condensedLayout ? 15 : 16, weight: .semibold))
369
                        .frame(width: condensedLayout ? 34 : 38, height: condensedLayout ? 34 : 38)
370
                }
371
            }
372
                .background(
373
                    Capsule(style: .continuous)
374
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
375
                )
376
        }
377
        .buttonStyle(.plain)
378
        .foregroundColor(enabled ? .primary : .secondary)
379
        .opacity(enabled ? 1 : 0.55)
380
        .accessibilityLabel(accessibilityLabel)
381
    }
382

            
383
    private func resetBufferButton(condensedLayout: Bool) -> some View {
384
        Button(action: {
385
            showResetConfirmation = true
386
        }) {
387
            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
388
                .font((condensedLayout ? Font.callout : .footnote).weight(.semibold))
389
                .padding(.horizontal, condensedLayout ? 14 : 16)
390
                .padding(.vertical, condensedLayout ? 10 : 11)
391
        }
392
        .buttonStyle(.plain)
393
        .foregroundColor(.white)
394
        .background(
395
            Capsule(style: .continuous)
396
                .fill(Color.red.opacity(0.8))
397
        )
398
        .fixedSize(horizontal: true, vertical: false)
399
        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
400
            Button("Reset series", role: .destructive) {
401
                measurements.resetSeries()
402
            }
403
            Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 weeks ago
404
        }
405
    }
406

            
407
    @ViewBuilder
408
    private func primaryAxisView(
409
        height: CGFloat,
Bogdan Timofte authored a week ago
410
        powerSeries: SeriesData,
411
        voltageSeries: SeriesData,
412
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
413
    ) -> some View {
414
        if displayPower {
415
            yAxisLabelsView(
416
                height: height,
417
                context: powerSeries.context,
Bogdan Timofte authored a week ago
418
                seriesKind: .power,
419
                measurementUnit: powerSeries.kind.unit,
420
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
421
            )
422
        } else if displayVoltage {
423
            yAxisLabelsView(
424
                height: height,
425
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
426
                seriesKind: .voltage,
427
                measurementUnit: voltageSeries.kind.unit,
428
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
429
            )
430
        } else if displayCurrent {
431
            yAxisLabelsView(
432
                height: height,
433
                context: currentSeries.context,
Bogdan Timofte authored a week ago
434
                seriesKind: .current,
435
                measurementUnit: currentSeries.kind.unit,
436
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
437
            )
438
        }
439
    }
440

            
441
    @ViewBuilder
442
    private func renderedChart(
Bogdan Timofte authored a week ago
443
        powerSeries: SeriesData,
444
        voltageSeries: SeriesData,
445
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
446
    ) -> some View {
447
        if self.displayPower {
448
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
449
                .opacity(0.72)
450
        } else {
451
            if self.displayVoltage {
452
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
453
                    .opacity(0.78)
454
            }
455
            if self.displayCurrent {
456
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
457
                    .opacity(0.78)
458
            }
459
        }
460
    }
461

            
462
    @ViewBuilder
463
    private func secondaryAxisView(
464
        height: CGFloat,
Bogdan Timofte authored a week ago
465
        powerSeries: SeriesData,
466
        voltageSeries: SeriesData,
467
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
468
    ) -> some View {
469
        if displayVoltage && displayCurrent {
470
            yAxisLabelsView(
471
                height: height,
472
                context: currentSeries.context,
Bogdan Timofte authored a week ago
473
                seriesKind: .current,
474
                measurementUnit: currentSeries.kind.unit,
475
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
476
            )
477
        } else {
478
            primaryAxisView(
479
                height: height,
480
                powerSeries: powerSeries,
481
                voltageSeries: voltageSeries,
482
                currentSeries: currentSeries
483
            )
Bogdan Timofte authored 2 weeks ago
484
        }
485
    }
Bogdan Timofte authored 2 weeks ago
486

            
487
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
488
        powerSeries: SeriesData,
489
        voltageSeries: SeriesData,
490
        currentSeries: SeriesData
491
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
492
        if displayPower {
Bogdan Timofte authored a week ago
493
            return powerSeries
Bogdan Timofte authored 2 weeks ago
494
        }
495
        if displayVoltage {
Bogdan Timofte authored a week ago
496
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
497
        }
498
        if displayCurrent {
Bogdan Timofte authored a week ago
499
            return currentSeries
Bogdan Timofte authored 2 weeks ago
500
        }
501
        return nil
502
    }
503

            
504
    private func series(
505
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
506
        kind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
507
        minimumYSpan: Double
Bogdan Timofte authored a week ago
508
    ) -> SeriesData {
Bogdan Timofte authored 2 weeks ago
509
        let points = measurement.points.filter { point in
510
            guard let timeRange else { return true }
511
            return timeRange.contains(point.timestamp)
512
        }
Bogdan Timofte authored a week ago
513
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
514
        let context = ChartContext()
Bogdan Timofte authored a week ago
515

            
516
        let autoBounds = automaticYBounds(
517
            for: samplePoints,
518
            minimumYSpan: minimumYSpan
519
        )
520
        let xBounds = xBounds(for: samplePoints)
521
        let lowerBound = resolvedLowerBound(
522
            for: kind,
523
            autoLowerBound: autoBounds.lowerBound
524
        )
525
        let upperBound = resolvedUpperBound(
526
            for: kind,
527
            lowerBound: lowerBound,
528
            autoUpperBound: autoBounds.upperBound,
529
            maximumSampleValue: samplePoints.map(\.value).max(),
530
            minimumYSpan: minimumYSpan
531
        )
532

            
533
        context.setBounds(
534
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
535
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
536
            yMin: CGFloat(lowerBound),
537
            yMax: CGFloat(upperBound)
538
        )
539

            
540
        return SeriesData(
541
            kind: kind,
542
            points: points,
543
            samplePoints: samplePoints,
544
            context: context,
545
            autoLowerBound: autoBounds.lowerBound,
546
            autoUpperBound: autoBounds.upperBound,
547
            maximumSampleValue: samplePoints.map(\.value).max()
548
        )
549
    }
550

            
551
    private var supportsSharedOrigin: Bool {
552
        displayVoltage && displayCurrent && !displayPower
553
    }
554

            
555
    private var pinnedOriginIsZero: Bool {
556
        if useSharedOrigin && supportsSharedOrigin {
557
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
558
        }
Bogdan Timofte authored a week ago
559

            
560
        if displayPower {
561
            return pinOrigin && powerAxisOrigin == 0
562
        }
563

            
564
        let visibleOrigins = [
565
            displayVoltage ? voltageAxisOrigin : nil,
566
            displayCurrent ? currentAxisOrigin : nil
567
        ]
568
        .compactMap { $0 }
569

            
570
        guard !visibleOrigins.isEmpty else { return false }
571
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
572
    }
573

            
574
    private func toggleSharedOrigin(
575
        voltageSeries: SeriesData,
576
        currentSeries: SeriesData
577
    ) {
578
        guard supportsSharedOrigin else { return }
579

            
580
        if useSharedOrigin {
581
            useSharedOrigin = false
582
            return
583
        }
584

            
585
        captureCurrentOrigins(
586
            voltageSeries: voltageSeries,
587
            currentSeries: currentSeries
588
        )
589
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
590
        useSharedOrigin = true
591
        pinOrigin = true
592
    }
593

            
594
    private func togglePinnedOrigin(
595
        voltageSeries: SeriesData,
596
        currentSeries: SeriesData
597
    ) {
598
        if pinOrigin {
599
            pinOrigin = false
600
            return
601
        }
602

            
603
        captureCurrentOrigins(
604
            voltageSeries: voltageSeries,
605
            currentSeries: currentSeries
606
        )
607
        pinOrigin = true
608
    }
609

            
610
    private func setVisibleOriginsToZero() {
611
        if useSharedOrigin && supportsSharedOrigin {
612
            sharedAxisOrigin = 0
613
            voltageAxisOrigin = 0
614
            currentAxisOrigin = 0
615
        } else {
616
            if displayPower {
617
                powerAxisOrigin = 0
618
            }
619
            if displayVoltage {
620
                voltageAxisOrigin = 0
621
            }
622
            if displayCurrent {
623
                currentAxisOrigin = 0
624
            }
625
        }
626

            
627
        pinOrigin = true
628
    }
629

            
630
    private func captureCurrentOrigins(
631
        voltageSeries: SeriesData,
632
        currentSeries: SeriesData
633
    ) {
634
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
635
        voltageAxisOrigin = voltageSeries.autoLowerBound
636
        currentAxisOrigin = currentSeries.autoLowerBound
637
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
638
    }
639

            
640
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
641
        switch kind {
642
        case .power:
643
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
644
        case .voltage:
645
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
646
                return sharedAxisOrigin
647
            }
648
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
649
        case .current:
650
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
651
                return sharedAxisOrigin
652
            }
653
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
654
        }
655
    }
656

            
657
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
658
        measurement.points.filter { point in
659
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
660
        }
661
    }
662

            
663
    private func xBounds(
664
        for samplePoints: [Measurements.Measurement.Point]
665
    ) -> ClosedRange<Date> {
666
        if let timeRange {
667
            return timeRange
668
        }
669

            
670
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
671
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
672

            
673
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
674
            return lowerBound...upperBound
675
        }
676

            
677
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
678
    }
679

            
680
    private func automaticYBounds(
681
        for samplePoints: [Measurements.Measurement.Point],
682
        minimumYSpan: Double
683
    ) -> (lowerBound: Double, upperBound: Double) {
684
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
685

            
686
        guard
687
            let minimumSampleValue = samplePoints.map(\.value).min(),
688
            let maximumSampleValue = samplePoints.map(\.value).max()
689
        else {
690
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
691
        }
Bogdan Timofte authored a week ago
692

            
693
        var lowerBound = minimumSampleValue
694
        var upperBound = maximumSampleValue
695
        let currentSpan = upperBound - lowerBound
696

            
697
        if currentSpan < minimumYSpan {
698
            let expansion = (minimumYSpan - currentSpan) / 2
699
            lowerBound -= expansion
700
            upperBound += expansion
701
        }
702

            
703
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
704
            let shift = -negativeAllowance - lowerBound
705
            lowerBound += shift
706
            upperBound += shift
707
        }
708

            
709
        let snappedLowerBound = snappedOriginValue(lowerBound)
710
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
711
        return (snappedLowerBound, resolvedUpperBound)
712
    }
713

            
714
    private func resolvedLowerBound(
715
        for kind: SeriesKind,
716
        autoLowerBound: Double
717
    ) -> Double {
718
        guard pinOrigin else { return autoLowerBound }
719

            
720
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
721
            return sharedAxisOrigin
722
        }
723

            
724
        switch kind {
725
        case .power:
726
            return powerAxisOrigin
727
        case .voltage:
728
            return voltageAxisOrigin
729
        case .current:
730
            return currentAxisOrigin
731
        }
732
    }
733

            
734
    private func resolvedUpperBound(
735
        for kind: SeriesKind,
736
        lowerBound: Double,
737
        autoUpperBound: Double,
738
        maximumSampleValue: Double?,
739
        minimumYSpan: Double
740
    ) -> Double {
741
        guard pinOrigin else {
742
            return autoUpperBound
743
        }
744

            
745
        return max(
746
            maximumSampleValue ?? lowerBound,
747
            lowerBound + minimumYSpan,
748
            autoUpperBound
749
        )
750
    }
751

            
752
    private func adjustOrigin(for kind: SeriesKind, translationHeight: CGFloat) {
753
        guard abs(translationHeight) >= axisSwipeThreshold else { return }
754

            
755
        let delta = translationHeight < 0 ? 1.0 : -1.0
756
        let baseline = displayedLowerBoundForSeries(kind)
757
        let proposedOrigin = snappedOriginValue(baseline + delta)
758

            
759
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
760
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
761
        } else {
762
            switch kind {
763
            case .power:
764
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
765
            case .voltage:
766
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
767
            case .current:
768
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
769
            }
770
        }
771

            
772
        pinOrigin = true
773
    }
774

            
775
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
776
        switch kind {
777
        case .power:
778
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
779
        case .voltage:
780
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
781
        case .current:
782
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
783
        }
784
    }
785

            
786
    private func maximumVisibleSharedOrigin() -> Double {
787
        min(
788
            maximumVisibleOrigin(for: .voltage),
789
            maximumVisibleOrigin(for: .current)
790
        )
791
    }
792

            
793
    private func snappedOriginValue(_ value: Double) -> Double {
794
        if value >= 0 {
795
            return value.rounded(.down)
796
        }
797

            
798
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
799
    }
Bogdan Timofte authored 2 weeks ago
800

            
801
    private func yGuidePosition(
802
        for labelIndex: Int,
803
        context: ChartContext,
804
        height: CGFloat
805
    ) -> CGFloat {
806
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
807
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
808
        return context.placeInRect(point: anchorPoint).y * height
809
    }
810

            
811
    private func xGuidePosition(
812
        for labelIndex: Int,
813
        context: ChartContext,
814
        width: CGFloat
815
    ) -> CGFloat {
816
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
817
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
818
        return context.placeInRect(point: anchorPoint).x * width
819
    }
Bogdan Timofte authored 2 weeks ago
820

            
821
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
822
    fileprivate func xAxisLabelsView(
823
        context: ChartContext
824
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
825
        var timeFormat: String?
826
        switch context.size.width {
827
        case 0..<3600: timeFormat = "HH:mm:ss"
828
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
829
        default: timeFormat = "E HH:mm"
830
        }
831
        let labels = (1...xLabels).map {
832
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
833
        }
Bogdan Timofte authored 2 weeks ago
834

            
835
        return HStack(spacing: chartSectionSpacing) {
836
            Color.clear
837
                .frame(width: axisColumnWidth)
838

            
839
            GeometryReader { geometry in
840
                let labelWidth = max(
841
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
842
                    1
843
                )
844

            
845
                ZStack(alignment: .topLeading) {
846
                    Path { path in
847
                        for labelIndex in 1...self.xLabels {
848
                            let x = xGuidePosition(
849
                                for: labelIndex,
850
                                context: context,
851
                                width: geometry.size.width
852
                            )
853
                            path.move(to: CGPoint(x: x, y: 0))
854
                            path.addLine(to: CGPoint(x: x, y: 6))
855
                        }
856
                    }
857
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
858

            
859
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
860
                        let labelIndex = item.offset + 1
861
                        let centerX = xGuidePosition(
862
                            for: labelIndex,
863
                            context: context,
864
                            width: geometry.size.width
865
                        )
866

            
867
                        Text(item.element)
868
                            .font(.caption.weight(.semibold))
869
                            .monospacedDigit()
870
                            .lineLimit(1)
871
                            .minimumScaleFactor(0.68)
872
                            .frame(width: labelWidth)
873
                            .position(
874
                                x: centerX,
875
                                y: geometry.size.height * 0.7
876
                            )
Bogdan Timofte authored 2 weeks ago
877
                    }
878
                }
Bogdan Timofte authored 2 weeks ago
879
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
880
            }
Bogdan Timofte authored 2 weeks ago
881

            
882
            Color.clear
883
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
884
        }
885
    }
886

            
Bogdan Timofte authored a week ago
887
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
888
        height: CGFloat,
889
        context: ChartContext,
Bogdan Timofte authored a week ago
890
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
891
        measurementUnit: String,
892
        tint: Color
893
    ) -> some View {
894
        GeometryReader { geometry in
895
            ZStack(alignment: .top) {
896
                ForEach(0..<yLabels, id: \.self) { row in
897
                    let labelIndex = yLabels - row
898

            
899
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
900
                        .font(.caption2.weight(.semibold))
901
                        .monospacedDigit()
902
                        .lineLimit(1)
903
                        .minimumScaleFactor(0.72)
904
                        .frame(width: max(geometry.size.width - 6, 0))
905
                        .position(
906
                            x: geometry.size.width / 2,
907
                            y: yGuidePosition(
908
                                for: labelIndex,
909
                                context: context,
910
                                height: geometry.size.height
911
                            )
912
                        )
Bogdan Timofte authored 2 weeks ago
913
                }
Bogdan Timofte authored 2 weeks ago
914

            
Bogdan Timofte authored 2 weeks ago
915
                Text(measurementUnit)
Bogdan Timofte authored 2 weeks ago
916
                    .font(.caption2.weight(.bold))
917
                    .foregroundColor(tint)
918
                    .padding(.horizontal, 6)
919
                    .padding(.vertical, 4)
920
                    .background(
921
                        Capsule(style: .continuous)
922
                            .fill(tint.opacity(0.14))
923
                    )
924
                    .padding(.top, 6)
Bogdan Timofte authored a week ago
925

            
926
                Text("Y \(Int(displayedLowerBoundForSeries(seriesKind)))")
927
                    .font(.caption2.weight(.semibold))
928
                    .foregroundColor(.secondary)
929
                    .padding(.bottom, 8)
930
                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
Bogdan Timofte authored 2 weeks ago
931
            }
932
        }
Bogdan Timofte authored 2 weeks ago
933
        .frame(height: height)
934
        .background(
935
            RoundedRectangle(cornerRadius: 16, style: .continuous)
936
                .fill(tint.opacity(0.12))
937
        )
938
        .overlay(
939
            RoundedRectangle(cornerRadius: 16, style: .continuous)
940
                .stroke(tint.opacity(0.20), lineWidth: 1)
941
        )
Bogdan Timofte authored a week ago
942
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
943
        .gesture(
944
            DragGesture(minimumDistance: axisSwipeThreshold)
945
                .onEnded { value in
946
                    adjustOrigin(for: seriesKind, translationHeight: value.translation.height)
947
                }
948
        )
Bogdan Timofte authored 2 weeks ago
949
    }
950

            
Bogdan Timofte authored 2 weeks ago
951
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
952
        GeometryReader { geometry in
953
            Path { path in
Bogdan Timofte authored 2 weeks ago
954
                for labelIndex in 1...self.yLabels {
955
                    let y = yGuidePosition(
956
                        for: labelIndex,
957
                        context: context,
958
                        height: geometry.size.height
959
                    )
960
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
961
                }
Bogdan Timofte authored 2 weeks ago
962
            }
963
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
964
        }
965
    }
966

            
Bogdan Timofte authored 2 weeks ago
967
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
968
        GeometryReader { geometry in
969
            Path { path in
970

            
Bogdan Timofte authored 2 weeks ago
971
                for labelIndex in 2..<self.xLabels {
972
                    let x = xGuidePosition(
973
                        for: labelIndex,
974
                        context: context,
975
                        width: geometry.size.width
976
                    )
977
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
978
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
979
                }
Bogdan Timofte authored 2 weeks ago
980
            }
981
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
982
        }
983
    }
Bogdan Timofte authored a week ago
984

            
985
    fileprivate func discontinuityMarkers(
986
        points: [Measurements.Measurement.Point],
987
        context: ChartContext
988
    ) -> some View {
989
        GeometryReader { geometry in
990
            Path { path in
991
                for point in points where point.isDiscontinuity {
992
                    let markerX = context.placeInRect(
993
                        point: CGPoint(
994
                            x: point.timestamp.timeIntervalSince1970,
995
                            y: context.origin.y
996
                        )
997
                    ).x * geometry.size.width
998
                    path.move(to: CGPoint(x: markerX, y: 0))
999
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1000
                }
1001
            }
1002
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1003
        }
1004
    }
Bogdan Timofte authored 2 weeks ago
1005

            
1006
}
1007

            
1008
struct Chart : View {
1009

            
Bogdan Timofte authored 2 weeks ago
1010
    let points: [Measurements.Measurement.Point]
1011
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1012
    var areaChart: Bool = false
1013
    var strokeColor: Color = .black
1014

            
1015
    var body : some View {
1016
        GeometryReader { geometry in
1017
            if self.areaChart {
1018
                self.path( geometry: geometry )
1019
                    .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)))
1020
            } else {
1021
                self.path( geometry: geometry )
1022
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1023
            }
1024
        }
1025
    }
1026

            
1027
    fileprivate func path(geometry: GeometryProxy) -> Path {
1028
        return Path { path in
Bogdan Timofte authored a week ago
1029
            var firstSample: Measurements.Measurement.Point?
1030
            var lastSample: Measurements.Measurement.Point?
1031
            var needsMove = true
1032

            
1033
            for point in points {
1034
                if point.isDiscontinuity {
1035
                    needsMove = true
1036
                    continue
1037
                }
1038

            
1039
                let item = context.placeInRect(point: point.point())
1040
                let renderedPoint = CGPoint(
1041
                    x: item.x * geometry.size.width,
1042
                    y: item.y * geometry.size.height
1043
                )
1044

            
1045
                if firstSample == nil {
1046
                    firstSample = point
1047
                }
1048
                lastSample = point
1049

            
1050
                if needsMove {
1051
                    path.move(to: renderedPoint)
1052
                    needsMove = false
1053
                } else {
1054
                    path.addLine(to: renderedPoint)
1055
                }
Bogdan Timofte authored 2 weeks ago
1056
            }
Bogdan Timofte authored a week ago
1057

            
1058
            if self.areaChart, let firstSample, let lastSample {
1059
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1060
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1061
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1062
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1063
                // MARK: Nu e nevoie. Fill inchide automat calea
1064
                // path.closeSubpath()
1065
            }
1066
        }
1067
    }
1068

            
1069
}