USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
1286 lines | 46.944kb
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 2 weeks ago
177
    var body: some View {
Bogdan Timofte authored a week ago
178
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
179
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
180
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
181
        let primarySeries = displayedPrimarySeries(
182
            powerSeries: powerSeries,
183
            voltageSeries: voltageSeries,
184
            currentSeries: currentSeries
185
        )
186

            
Bogdan Timofte authored 2 weeks ago
187
        Group {
Bogdan Timofte authored 2 weeks ago
188
            if let primarySeries {
189
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
190
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
191

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

            
195
                        VStack(spacing: 6) {
196
                            HStack(spacing: chartSectionSpacing) {
197
                                primaryAxisView(
198
                                    height: plotHeight,
199
                                    powerSeries: powerSeries,
200
                                    voltageSeries: voltageSeries,
201
                                    currentSeries: currentSeries
202
                                )
203
                                .frame(width: axisColumnWidth, height: plotHeight)
204

            
205
                                ZStack {
206
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
207
                                        .fill(Color.primary.opacity(0.05))
208

            
209
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
210
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
211

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

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

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

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

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

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

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

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

            
340
    private func scaleControlsPill(
341
        voltageSeries: SeriesData,
342
        currentSeries: SeriesData
343
    ) -> some View {
344
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 6 days ago
345
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 6 days ago
346

            
347
        return originControlsRow(
348
            voltageSeries: voltageSeries,
349
            currentSeries: currentSeries,
350
            condensedLayout: condensedLayout,
351
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
352
        )
353
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
354
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
355
        .background(
356
            Capsule(style: .continuous)
Bogdan Timofte authored 6 days ago
357
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 6 days ago
358
        )
359
        .overlay(
360
            Capsule(style: .continuous)
361
                .stroke(
Bogdan Timofte authored 6 days ago
362
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 6 days ago
363
                    lineWidth: 1
364
                )
365
        )
366
    }
367

            
Bogdan Timofte authored a week ago
368
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
369
        HStack(spacing: condensedLayout ? 6 : 8) {
370
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
371
                displayVoltage.toggle()
372
                if displayVoltage {
373
                    displayPower = false
374
                }
375
            }
376

            
377
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
378
                displayCurrent.toggle()
379
                if displayCurrent {
380
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
381
                }
Bogdan Timofte authored a week ago
382
            }
383

            
384
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
385
                displayPower.toggle()
386
                if displayPower {
387
                    displayCurrent = false
388
                    displayVoltage = false
389
                }
390
            }
391
        }
392
    }
393

            
394
    private func originControlsRow(
395
        voltageSeries: SeriesData,
396
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
397
        condensedLayout: Bool,
398
        showsLabel: Bool
Bogdan Timofte authored a week ago
399
    ) -> some View {
400
        HStack(spacing: condensedLayout ? 8 : 10) {
401
            symbolControlChip(
402
                systemImage: "equal.circle",
403
                enabled: supportsSharedOrigin,
404
                active: useSharedOrigin && supportsSharedOrigin,
405
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
406
                showsLabel: showsLabel,
407
                label: "Match Y Scale",
408
                accessibilityLabel: "Match Y scale"
Bogdan Timofte authored a week ago
409
            ) {
410
                toggleSharedOrigin(
411
                    voltageSeries: voltageSeries,
412
                    currentSeries: currentSeries
413
                )
414
            }
415

            
416
            symbolControlChip(
417
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
418
                enabled: true,
419
                active: pinOrigin,
420
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
421
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
422
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
423
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
424
            ) {
425
                togglePinnedOrigin(
426
                    voltageSeries: voltageSeries,
427
                    currentSeries: currentSeries
428
                )
429
            }
430

            
431
            symbolControlChip(
432
                systemImage: "0.circle",
433
                enabled: true,
434
                active: pinnedOriginIsZero,
435
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
436
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
437
                label: "Origin 0",
438
                accessibilityLabel: "Set origin to zero"
439
            ) {
440
                setVisibleOriginsToZero()
441
            }
Bogdan Timofte authored 6 days ago
442

            
Bogdan Timofte authored a week ago
443
        }
444
    }
445

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

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

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

            
Bogdan Timofte authored 6 days ago
537
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
538
        if isLargeDisplay {
539
            return .body.weight(.semibold)
540
        }
541
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
542
    }
543

            
544
    private func controlChipFont(condensedLayout: Bool) -> Font {
545
        if isLargeDisplay {
546
            return .callout.weight(.semibold)
547
        }
548
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
549
    }
550

            
Bogdan Timofte authored 2 weeks ago
551
    @ViewBuilder
552
    private func primaryAxisView(
553
        height: CGFloat,
Bogdan Timofte authored a week ago
554
        powerSeries: SeriesData,
555
        voltageSeries: SeriesData,
556
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
557
    ) -> some View {
558
        if displayPower {
559
            yAxisLabelsView(
560
                height: height,
561
                context: powerSeries.context,
Bogdan Timofte authored a week ago
562
                seriesKind: .power,
563
                measurementUnit: powerSeries.kind.unit,
564
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
565
            )
566
        } else if displayVoltage {
567
            yAxisLabelsView(
568
                height: height,
569
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
570
                seriesKind: .voltage,
571
                measurementUnit: voltageSeries.kind.unit,
572
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
573
            )
574
        } else if displayCurrent {
575
            yAxisLabelsView(
576
                height: height,
577
                context: currentSeries.context,
Bogdan Timofte authored a week ago
578
                seriesKind: .current,
579
                measurementUnit: currentSeries.kind.unit,
580
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
581
            )
582
        }
583
    }
584

            
585
    @ViewBuilder
586
    private func renderedChart(
Bogdan Timofte authored a week ago
587
        powerSeries: SeriesData,
588
        voltageSeries: SeriesData,
589
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
590
    ) -> some View {
591
        if self.displayPower {
592
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
593
                .opacity(0.72)
594
        } else {
595
            if self.displayVoltage {
596
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
597
                    .opacity(0.78)
598
            }
599
            if self.displayCurrent {
600
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
601
                    .opacity(0.78)
602
            }
603
        }
604
    }
605

            
606
    @ViewBuilder
607
    private func secondaryAxisView(
608
        height: CGFloat,
Bogdan Timofte authored a week ago
609
        powerSeries: SeriesData,
610
        voltageSeries: SeriesData,
611
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
612
    ) -> some View {
613
        if displayVoltage && displayCurrent {
614
            yAxisLabelsView(
615
                height: height,
616
                context: currentSeries.context,
Bogdan Timofte authored a week ago
617
                seriesKind: .current,
618
                measurementUnit: currentSeries.kind.unit,
619
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
620
            )
621
        } else {
622
            primaryAxisView(
623
                height: height,
624
                powerSeries: powerSeries,
625
                voltageSeries: voltageSeries,
626
                currentSeries: currentSeries
627
            )
Bogdan Timofte authored 2 weeks ago
628
        }
629
    }
Bogdan Timofte authored 2 weeks ago
630

            
631
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
632
        powerSeries: SeriesData,
633
        voltageSeries: SeriesData,
634
        currentSeries: SeriesData
635
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
636
        if displayPower {
Bogdan Timofte authored a week ago
637
            return powerSeries
Bogdan Timofte authored 2 weeks ago
638
        }
639
        if displayVoltage {
Bogdan Timofte authored a week ago
640
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
641
        }
642
        if displayCurrent {
Bogdan Timofte authored a week ago
643
            return currentSeries
Bogdan Timofte authored 2 weeks ago
644
        }
645
        return nil
646
    }
647

            
648
    private func series(
649
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
650
        kind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
651
        minimumYSpan: Double
Bogdan Timofte authored a week ago
652
    ) -> SeriesData {
Bogdan Timofte authored 2 weeks ago
653
        let points = measurement.points.filter { point in
654
            guard let timeRange else { return true }
655
            return timeRange.contains(point.timestamp)
656
        }
Bogdan Timofte authored a week ago
657
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
658
        let context = ChartContext()
Bogdan Timofte authored a week ago
659

            
660
        let autoBounds = automaticYBounds(
661
            for: samplePoints,
662
            minimumYSpan: minimumYSpan
663
        )
664
        let xBounds = xBounds(for: samplePoints)
665
        let lowerBound = resolvedLowerBound(
666
            for: kind,
667
            autoLowerBound: autoBounds.lowerBound
668
        )
669
        let upperBound = resolvedUpperBound(
670
            for: kind,
671
            lowerBound: lowerBound,
672
            autoUpperBound: autoBounds.upperBound,
673
            maximumSampleValue: samplePoints.map(\.value).max(),
674
            minimumYSpan: minimumYSpan
675
        )
676

            
677
        context.setBounds(
678
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
679
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
680
            yMin: CGFloat(lowerBound),
681
            yMax: CGFloat(upperBound)
682
        )
683

            
684
        return SeriesData(
685
            kind: kind,
686
            points: points,
687
            samplePoints: samplePoints,
688
            context: context,
689
            autoLowerBound: autoBounds.lowerBound,
690
            autoUpperBound: autoBounds.upperBound,
691
            maximumSampleValue: samplePoints.map(\.value).max()
692
        )
693
    }
694

            
695
    private var supportsSharedOrigin: Bool {
696
        displayVoltage && displayCurrent && !displayPower
697
    }
698

            
Bogdan Timofte authored 6 days ago
699
    private var minimumSharedScaleSpan: Double {
700
        max(minimumVoltageSpan, minimumCurrentSpan)
701
    }
702

            
Bogdan Timofte authored a week ago
703
    private var pinnedOriginIsZero: Bool {
704
        if useSharedOrigin && supportsSharedOrigin {
705
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
706
        }
Bogdan Timofte authored a week ago
707

            
708
        if displayPower {
709
            return pinOrigin && powerAxisOrigin == 0
710
        }
711

            
712
        let visibleOrigins = [
713
            displayVoltage ? voltageAxisOrigin : nil,
714
            displayCurrent ? currentAxisOrigin : nil
715
        ]
716
        .compactMap { $0 }
717

            
718
        guard !visibleOrigins.isEmpty else { return false }
719
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
720
    }
721

            
722
    private func toggleSharedOrigin(
723
        voltageSeries: SeriesData,
724
        currentSeries: SeriesData
725
    ) {
726
        guard supportsSharedOrigin else { return }
727

            
728
        if useSharedOrigin {
729
            useSharedOrigin = false
730
            return
731
        }
732

            
733
        captureCurrentOrigins(
734
            voltageSeries: voltageSeries,
735
            currentSeries: currentSeries
736
        )
737
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
738
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
739
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
740
        useSharedOrigin = true
741
        pinOrigin = true
742
    }
743

            
744
    private func togglePinnedOrigin(
745
        voltageSeries: SeriesData,
746
        currentSeries: SeriesData
747
    ) {
748
        if pinOrigin {
749
            pinOrigin = false
750
            return
751
        }
752

            
753
        captureCurrentOrigins(
754
            voltageSeries: voltageSeries,
755
            currentSeries: currentSeries
756
        )
757
        pinOrigin = true
758
    }
759

            
760
    private func setVisibleOriginsToZero() {
761
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
762
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
763
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
764
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
765
            voltageAxisOrigin = 0
766
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
767
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
768
        } else {
769
            if displayPower {
770
                powerAxisOrigin = 0
771
            }
772
            if displayVoltage {
773
                voltageAxisOrigin = 0
774
            }
775
            if displayCurrent {
776
                currentAxisOrigin = 0
777
            }
778
        }
779

            
780
        pinOrigin = true
781
    }
782

            
783
    private func captureCurrentOrigins(
784
        voltageSeries: SeriesData,
785
        currentSeries: SeriesData
786
    ) {
787
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
788
        voltageAxisOrigin = voltageSeries.autoLowerBound
789
        currentAxisOrigin = currentSeries.autoLowerBound
790
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
791
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
792
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
793
    }
794

            
795
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
796
        switch kind {
797
        case .power:
798
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
799
        case .voltage:
800
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
801
                return sharedAxisOrigin
802
            }
803
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
804
        case .current:
805
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
806
                return sharedAxisOrigin
807
            }
808
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
809
        }
810
    }
811

            
812
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
813
        measurement.points.filter { point in
814
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
815
        }
816
    }
817

            
818
    private func xBounds(
819
        for samplePoints: [Measurements.Measurement.Point]
820
    ) -> ClosedRange<Date> {
821
        if let timeRange {
822
            return timeRange
823
        }
824

            
825
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
826
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
827

            
828
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
829
            return lowerBound...upperBound
830
        }
831

            
832
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
833
    }
834

            
835
    private func automaticYBounds(
836
        for samplePoints: [Measurements.Measurement.Point],
837
        minimumYSpan: Double
838
    ) -> (lowerBound: Double, upperBound: Double) {
839
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
840

            
841
        guard
842
            let minimumSampleValue = samplePoints.map(\.value).min(),
843
            let maximumSampleValue = samplePoints.map(\.value).max()
844
        else {
845
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
846
        }
Bogdan Timofte authored a week ago
847

            
848
        var lowerBound = minimumSampleValue
849
        var upperBound = maximumSampleValue
850
        let currentSpan = upperBound - lowerBound
851

            
852
        if currentSpan < minimumYSpan {
853
            let expansion = (minimumYSpan - currentSpan) / 2
854
            lowerBound -= expansion
855
            upperBound += expansion
856
        }
857

            
858
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
859
            let shift = -negativeAllowance - lowerBound
860
            lowerBound += shift
861
            upperBound += shift
862
        }
863

            
864
        let snappedLowerBound = snappedOriginValue(lowerBound)
865
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
866
        return (snappedLowerBound, resolvedUpperBound)
867
    }
868

            
869
    private func resolvedLowerBound(
870
        for kind: SeriesKind,
871
        autoLowerBound: Double
872
    ) -> Double {
873
        guard pinOrigin else { return autoLowerBound }
874

            
875
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
876
            return sharedAxisOrigin
877
        }
878

            
879
        switch kind {
880
        case .power:
881
            return powerAxisOrigin
882
        case .voltage:
883
            return voltageAxisOrigin
884
        case .current:
885
            return currentAxisOrigin
886
        }
887
    }
888

            
889
    private func resolvedUpperBound(
890
        for kind: SeriesKind,
891
        lowerBound: Double,
892
        autoUpperBound: Double,
893
        maximumSampleValue: Double?,
894
        minimumYSpan: Double
895
    ) -> Double {
896
        guard pinOrigin else {
897
            return autoUpperBound
898
        }
899

            
Bogdan Timofte authored 6 days ago
900
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
901
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
902
        }
903

            
Bogdan Timofte authored a week ago
904
        return max(
905
            maximumSampleValue ?? lowerBound,
906
            lowerBound + minimumYSpan,
907
            autoUpperBound
908
        )
909
    }
910

            
Bogdan Timofte authored 6 days ago
911
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
912
        let baseline = displayedLowerBoundForSeries(kind)
913
        let proposedOrigin = snappedOriginValue(baseline + delta)
914

            
915
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
916
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
917
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
918
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
919
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
920
        } else {
921
            switch kind {
922
            case .power:
923
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
924
            case .voltage:
925
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
926
            case .current:
927
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
928
            }
929
        }
930

            
931
        pinOrigin = true
932
    }
933

            
Bogdan Timofte authored 6 days ago
934
    private func clearOriginOffset(for kind: SeriesKind) {
935
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
936
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
937
            sharedAxisOrigin = 0
938
            sharedAxisUpperBound = currentSpan
939
            ensureSharedScaleSpan()
940
            voltageAxisOrigin = 0
941
            currentAxisOrigin = 0
942
        } else {
943
            switch kind {
944
            case .power:
945
                powerAxisOrigin = 0
946
            case .voltage:
947
                voltageAxisOrigin = 0
948
            case .current:
949
                currentAxisOrigin = 0
950
            }
951
        }
952

            
953
        pinOrigin = true
954
    }
955

            
956
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
957
        guard totalHeight > 1 else { return }
958

            
959
        let normalized = max(0, min(1, locationY / totalHeight))
960
        if normalized < (1.0 / 3.0) {
961
            applyOriginDelta(-1, kind: kind)
962
        } else if normalized < (2.0 / 3.0) {
963
            clearOriginOffset(for: kind)
964
        } else {
965
            applyOriginDelta(1, kind: kind)
966
        }
967
    }
968

            
Bogdan Timofte authored a week ago
969
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
970
        switch kind {
971
        case .power:
972
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
973
        case .voltage:
974
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
975
        case .current:
976
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
977
        }
978
    }
979

            
980
    private func maximumVisibleSharedOrigin() -> Double {
981
        min(
982
            maximumVisibleOrigin(for: .voltage),
983
            maximumVisibleOrigin(for: .current)
984
        )
985
    }
986

            
Bogdan Timofte authored 6 days ago
987
    private func ensureSharedScaleSpan() {
988
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
989
    }
990

            
Bogdan Timofte authored a week ago
991
    private func snappedOriginValue(_ value: Double) -> Double {
992
        if value >= 0 {
993
            return value.rounded(.down)
994
        }
995

            
996
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
997
    }
Bogdan Timofte authored 2 weeks ago
998

            
999
    private func yGuidePosition(
1000
        for labelIndex: Int,
1001
        context: ChartContext,
1002
        height: CGFloat
1003
    ) -> CGFloat {
1004
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1005
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1006
        return context.placeInRect(point: anchorPoint).y * height
1007
    }
1008

            
1009
    private func xGuidePosition(
1010
        for labelIndex: Int,
1011
        context: ChartContext,
1012
        width: CGFloat
1013
    ) -> CGFloat {
1014
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1015
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1016
        return context.placeInRect(point: anchorPoint).x * width
1017
    }
Bogdan Timofte authored 2 weeks ago
1018

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

            
1039
        return HStack(spacing: chartSectionSpacing) {
1040
            Color.clear
1041
                .frame(width: axisColumnWidth)
1042

            
1043
            GeometryReader { geometry in
1044
                let labelWidth = max(
1045
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1046
                    1
1047
                )
1048

            
1049
                ZStack(alignment: .topLeading) {
1050
                    Path { path in
1051
                        for labelIndex in 1...self.xLabels {
1052
                            let x = xGuidePosition(
1053
                                for: labelIndex,
1054
                                context: context,
1055
                                width: geometry.size.width
1056
                            )
1057
                            path.move(to: CGPoint(x: x, y: 0))
1058
                            path.addLine(to: CGPoint(x: x, y: 6))
1059
                        }
1060
                    }
1061
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1062

            
1063
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1064
                        let labelIndex = item.offset + 1
1065
                        let centerX = xGuidePosition(
1066
                            for: labelIndex,
1067
                            context: context,
1068
                            width: geometry.size.width
1069
                        )
1070

            
1071
                        Text(item.element)
Bogdan Timofte authored 6 days ago
1072
                            .font(axisLabelFont)
Bogdan Timofte authored 2 weeks ago
1073
                            .monospacedDigit()
1074
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1075
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1076
                            .frame(width: labelWidth)
1077
                            .position(
1078
                                x: centerX,
1079
                                y: geometry.size.height * 0.7
1080
                            )
Bogdan Timofte authored 2 weeks ago
1081
                    }
1082
                }
Bogdan Timofte authored 2 weeks ago
1083
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1084
            }
Bogdan Timofte authored 2 weeks ago
1085

            
1086
            Color.clear
1087
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1088
        }
1089
    }
1090

            
Bogdan Timofte authored a week ago
1091
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1092
        height: CGFloat,
1093
        context: ChartContext,
Bogdan Timofte authored a week ago
1094
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1095
        measurementUnit: String,
1096
        tint: Color
1097
    ) -> some View {
Bogdan Timofte authored 6 days ago
1098
        let yAxisFont: Font = {
1099
            if isIPhone && isPortraitLayout {
1100
                return .caption2.weight(.semibold)
1101
            }
1102
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1103
        }()
1104

            
1105
        let unitFont: Font = {
1106
            if isIPhone && isPortraitLayout {
1107
                return .caption2.weight(.bold)
1108
            }
1109
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1110
        }()
1111

            
1112
        return GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1113
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1114
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1115
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1116

            
Bogdan Timofte authored 2 weeks ago
1117
            ZStack(alignment: .top) {
1118
                ForEach(0..<yLabels, id: \.self) { row in
1119
                    let labelIndex = yLabels - row
1120

            
1121
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1122
                        .font(yAxisFont)
Bogdan Timofte authored 2 weeks ago
1123
                        .monospacedDigit()
1124
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1125
                        .minimumScaleFactor(0.8)
1126
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1127
                        .position(
1128
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1129
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1130
                                for: labelIndex,
1131
                                context: context,
Bogdan Timofte authored 6 days ago
1132
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1133
                            )
1134
                        )
Bogdan Timofte authored 2 weeks ago
1135
                }
Bogdan Timofte authored 2 weeks ago
1136

            
Bogdan Timofte authored 2 weeks ago
1137
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1138
                    .font(unitFont)
Bogdan Timofte authored 2 weeks ago
1139
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1140
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1141
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1142
                    .background(
1143
                        Capsule(style: .continuous)
1144
                            .fill(tint.opacity(0.14))
1145
                    )
Bogdan Timofte authored 6 days ago
1146
                    .padding(.top, 8)
1147

            
Bogdan Timofte authored 2 weeks ago
1148
            }
1149
        }
Bogdan Timofte authored 2 weeks ago
1150
        .frame(height: height)
1151
        .background(
1152
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1153
                .fill(tint.opacity(0.12))
1154
        )
1155
        .overlay(
1156
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1157
                .stroke(tint.opacity(0.20), lineWidth: 1)
1158
        )
Bogdan Timofte authored a week ago
1159
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1160
        .gesture(
Bogdan Timofte authored 6 days ago
1161
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1162
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1163
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1164
                }
1165
        )
Bogdan Timofte authored 2 weeks ago
1166
    }
1167

            
Bogdan Timofte authored 2 weeks ago
1168
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1169
        GeometryReader { geometry in
1170
            Path { path in
Bogdan Timofte authored 2 weeks ago
1171
                for labelIndex in 1...self.yLabels {
1172
                    let y = yGuidePosition(
1173
                        for: labelIndex,
1174
                        context: context,
1175
                        height: geometry.size.height
1176
                    )
1177
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1178
                }
Bogdan Timofte authored 2 weeks ago
1179
            }
1180
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1181
        }
1182
    }
1183

            
Bogdan Timofte authored 2 weeks ago
1184
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1185
        GeometryReader { geometry in
1186
            Path { path in
1187

            
Bogdan Timofte authored 2 weeks ago
1188
                for labelIndex in 2..<self.xLabels {
1189
                    let x = xGuidePosition(
1190
                        for: labelIndex,
1191
                        context: context,
1192
                        width: geometry.size.width
1193
                    )
1194
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1195
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1196
                }
Bogdan Timofte authored 2 weeks ago
1197
            }
1198
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1199
        }
1200
    }
Bogdan Timofte authored a week ago
1201

            
1202
    fileprivate func discontinuityMarkers(
1203
        points: [Measurements.Measurement.Point],
1204
        context: ChartContext
1205
    ) -> some View {
1206
        GeometryReader { geometry in
1207
            Path { path in
1208
                for point in points where point.isDiscontinuity {
1209
                    let markerX = context.placeInRect(
1210
                        point: CGPoint(
1211
                            x: point.timestamp.timeIntervalSince1970,
1212
                            y: context.origin.y
1213
                        )
1214
                    ).x * geometry.size.width
1215
                    path.move(to: CGPoint(x: markerX, y: 0))
1216
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1217
                }
1218
            }
1219
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1220
        }
1221
    }
Bogdan Timofte authored 2 weeks ago
1222

            
1223
}
1224

            
1225
struct Chart : View {
1226

            
Bogdan Timofte authored 2 weeks ago
1227
    let points: [Measurements.Measurement.Point]
1228
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1229
    var areaChart: Bool = false
1230
    var strokeColor: Color = .black
1231

            
1232
    var body : some View {
1233
        GeometryReader { geometry in
1234
            if self.areaChart {
1235
                self.path( geometry: geometry )
1236
                    .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)))
1237
            } else {
1238
                self.path( geometry: geometry )
1239
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1240
            }
1241
        }
1242
    }
1243

            
1244
    fileprivate func path(geometry: GeometryProxy) -> Path {
1245
        return Path { path in
Bogdan Timofte authored a week ago
1246
            var firstSample: Measurements.Measurement.Point?
1247
            var lastSample: Measurements.Measurement.Point?
1248
            var needsMove = true
1249

            
1250
            for point in points {
1251
                if point.isDiscontinuity {
1252
                    needsMove = true
1253
                    continue
1254
                }
1255

            
1256
                let item = context.placeInRect(point: point.point())
1257
                let renderedPoint = CGPoint(
1258
                    x: item.x * geometry.size.width,
1259
                    y: item.y * geometry.size.height
1260
                )
1261

            
1262
                if firstSample == nil {
1263
                    firstSample = point
1264
                }
1265
                lastSample = point
1266

            
1267
                if needsMove {
1268
                    path.move(to: renderedPoint)
1269
                    needsMove = false
1270
                } else {
1271
                    path.addLine(to: renderedPoint)
1272
                }
Bogdan Timofte authored 2 weeks ago
1273
            }
Bogdan Timofte authored a week ago
1274

            
1275
            if self.areaChart, let firstSample, let lastSample {
1276
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1277
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1278
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1279
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1280
                // MARK: Nu e nevoie. Fill inchide automat calea
1281
                // path.closeSubpath()
1282
            }
1283
        }
1284
    }
1285

            
1286
}