USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
1226 lines | 44.806kb
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 a week ago
106
    private var plotSectionHeight: CGFloat {
107
        if availableSize == .zero {
Bogdan Timofte authored 6 days ago
108
            return compactLayout ? 300 : 380
109
        }
110

            
111
        if isPortraitLayout {
112
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
113
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
114
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored a week ago
115
        }
116

            
117
        if compactLayout {
118
            return min(max(availableSize.height * 0.36, 240), 300)
119
        }
120

            
121
        return min(max(availableSize.height * 0.5, 300), 440)
122
    }
123

            
124
    private var stackedToolbarLayout: Bool {
125
        if availableSize.width > 0 {
126
            return availableSize.width < 640
127
        }
128

            
129
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
130
    }
131

            
132
    private var showsLabeledOriginControls: Bool {
133
        !compactLayout && !stackedToolbarLayout
134
    }
135

            
Bogdan Timofte authored 6 days ago
136
    private var isLargeDisplay: Bool {
137
        if availableSize.width > 0 {
138
            return availableSize.width >= 900 || availableSize.height >= 700
139
        }
140
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
141
    }
142

            
143
    private var chartBaseFont: Font {
144
        isLargeDisplay ? .callout : .footnote
145
    }
146

            
Bogdan Timofte authored 2 weeks ago
147
    var body: some View {
Bogdan Timofte authored a week ago
148
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
149
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
150
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
151
        let primarySeries = displayedPrimarySeries(
152
            powerSeries: powerSeries,
153
            voltageSeries: voltageSeries,
154
            currentSeries: currentSeries
155
        )
156

            
Bogdan Timofte authored 2 weeks ago
157
        Group {
Bogdan Timofte authored 2 weeks ago
158
            if let primarySeries {
159
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
160
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
161

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

            
165
                        VStack(spacing: 6) {
166
                            HStack(spacing: chartSectionSpacing) {
167
                                primaryAxisView(
168
                                    height: plotHeight,
169
                                    powerSeries: powerSeries,
170
                                    voltageSeries: voltageSeries,
171
                                    currentSeries: currentSeries
172
                                )
173
                                .frame(width: axisColumnWidth, height: plotHeight)
174

            
175
                                ZStack {
176
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
177
                                        .fill(Color.primary.opacity(0.05))
178

            
179
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
180
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
181

            
182
                                    horizontalGuides(context: primarySeries.context)
183
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
184
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
185
                                    renderedChart(
186
                                        powerSeries: powerSeries,
187
                                        voltageSeries: voltageSeries,
188
                                        currentSeries: currentSeries
189
                                    )
Bogdan Timofte authored 2 weeks ago
190
                                }
Bogdan Timofte authored 2 weeks ago
191
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
192
                                .frame(maxWidth: .infinity)
193
                                .frame(height: plotHeight)
194

            
195
                                secondaryAxisView(
196
                                    height: plotHeight,
197
                                    powerSeries: powerSeries,
198
                                    voltageSeries: voltageSeries,
199
                                    currentSeries: currentSeries
200
                                )
201
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
202
                            }
Bogdan Timofte authored 6 days ago
203
                            .overlay(alignment: .bottom) {
204
                                scaleControlsPill(
205
                                    voltageSeries: voltageSeries,
206
                                    currentSeries: currentSeries
207
                                )
208
                                .padding(.bottom, compactLayout ? 6 : 10)
209
                            }
Bogdan Timofte authored 2 weeks ago
210

            
211
                            xAxisLabelsView(context: primarySeries.context)
212
                            .frame(height: xAxisHeight)
Bogdan Timofte authored 2 weeks ago
213
                        }
Bogdan Timofte authored 2 weeks ago
214
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
215
                    }
Bogdan Timofte authored a week ago
216
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 weeks ago
217
                }
Bogdan Timofte authored 2 weeks ago
218
            } else {
219
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
220
                    chartToggleBar()
Bogdan Timofte authored a week ago
221
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 weeks ago
222
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
223
                }
224
            }
Bogdan Timofte authored 2 weeks ago
225
        }
Bogdan Timofte authored 6 days ago
226
        .font(chartBaseFont)
Bogdan Timofte authored 2 weeks ago
227
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a week ago
228
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
229
            guard timeRange == nil else { return }
230
            chartNow = now
231
        }
Bogdan Timofte authored 2 weeks ago
232
    }
233

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

            
Bogdan Timofte authored 6 days ago
238
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
239
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
240
        }
241
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
242
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
243
        .background(
244
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
245
                .fill(Color.primary.opacity(0.045))
246
        )
247
        .overlay(
248
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
249
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
250
        )
Bogdan Timofte authored a week ago
251

            
Bogdan Timofte authored 6 days ago
252
        return Group {
Bogdan Timofte authored a week ago
253
            if stackedToolbarLayout {
Bogdan Timofte authored 6 days ago
254
                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
255
                    controlsPanel
256
                    HStack {
257
                        Spacer(minLength: 0)
258
                        resetBufferButton(condensedLayout: condensedLayout)
259
                    }
Bogdan Timofte authored 2 weeks ago
260
                }
Bogdan Timofte authored a week ago
261
            } else {
Bogdan Timofte authored 6 days ago
262
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
263
                    controlsPanel
Bogdan Timofte authored a week ago
264
                    Spacer(minLength: 0)
265
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
266
                }
Bogdan Timofte authored a week ago
267
            }
268
        }
269
        .frame(maxWidth: .infinity, alignment: .leading)
270
    }
271

            
Bogdan Timofte authored 6 days ago
272
    private var shouldFloatScaleControlsOverChart: Bool {
273
        #if os(iOS)
274
        if availableSize.width > 0, availableSize.height > 0 {
275
            return availableSize.width > availableSize.height
276
        }
277
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
278
        #else
279
        return false
280
        #endif
281
    }
282

            
283
    private func scaleControlsPill(
284
        voltageSeries: SeriesData,
285
        currentSeries: SeriesData
286
    ) -> some View {
287
        let condensedLayout = compactLayout || verticalSizeClass == .compact
288

            
289
        return originControlsRow(
290
            voltageSeries: voltageSeries,
291
            currentSeries: currentSeries,
292
            condensedLayout: condensedLayout,
293
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
294
        )
295
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
296
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
297
        .background(
298
            Capsule(style: .continuous)
299
                .fill(shouldFloatScaleControlsOverChart ? Color.clear : Color.primary.opacity(0.08))
300
        )
301
        .overlay(
302
            Capsule(style: .continuous)
303
                .stroke(
304
                    shouldFloatScaleControlsOverChart ? Color.clear : Color.secondary.opacity(0.18),
305
                    lineWidth: 1
306
                )
307
        )
308
    }
309

            
Bogdan Timofte authored a week ago
310
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
311
        HStack(spacing: condensedLayout ? 6 : 8) {
312
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
313
                displayVoltage.toggle()
314
                if displayVoltage {
315
                    displayPower = false
316
                }
317
            }
318

            
319
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
320
                displayCurrent.toggle()
321
                if displayCurrent {
322
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
323
                }
Bogdan Timofte authored a week ago
324
            }
325

            
326
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
327
                displayPower.toggle()
328
                if displayPower {
329
                    displayCurrent = false
330
                    displayVoltage = false
331
                }
332
            }
333
        }
334
    }
335

            
336
    private func originControlsRow(
337
        voltageSeries: SeriesData,
338
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
339
        condensedLayout: Bool,
340
        showsLabel: Bool
Bogdan Timofte authored a week ago
341
    ) -> some View {
342
        HStack(spacing: condensedLayout ? 8 : 10) {
343
            symbolControlChip(
344
                systemImage: "equal.circle",
345
                enabled: supportsSharedOrigin,
346
                active: useSharedOrigin && supportsSharedOrigin,
347
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
348
                showsLabel: showsLabel,
349
                label: "Match Y Scale",
350
                accessibilityLabel: "Match Y scale"
Bogdan Timofte authored a week ago
351
            ) {
352
                toggleSharedOrigin(
353
                    voltageSeries: voltageSeries,
354
                    currentSeries: currentSeries
355
                )
356
            }
357

            
358
            symbolControlChip(
359
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
360
                enabled: true,
361
                active: pinOrigin,
362
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
363
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
364
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
365
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
366
            ) {
367
                togglePinnedOrigin(
368
                    voltageSeries: voltageSeries,
369
                    currentSeries: currentSeries
370
                )
371
            }
372

            
373
            symbolControlChip(
374
                systemImage: "0.circle",
375
                enabled: true,
376
                active: pinnedOriginIsZero,
377
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
378
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
379
                label: "Origin 0",
380
                accessibilityLabel: "Set origin to zero"
381
            ) {
382
                setVisibleOriginsToZero()
383
            }
Bogdan Timofte authored 6 days ago
384

            
Bogdan Timofte authored a week ago
385
        }
386
    }
387

            
388
    private func seriesToggleButton(
389
        title: String,
390
        isOn: Bool,
391
        condensedLayout: Bool,
392
        action: @escaping () -> Void
393
    ) -> some View {
394
        Button(action: action) {
395
            Text(title)
Bogdan Timofte authored 6 days ago
396
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
397
                .lineLimit(1)
398
                .minimumScaleFactor(0.82)
399
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 6 days ago
400
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
401
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
402
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored a week ago
403
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
404
                .background(
405
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
406
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
407
                )
408
                .overlay(
409
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
410
                        .stroke(Color.blue, lineWidth: 1.5)
411
                )
412
        }
413
        .buttonStyle(.plain)
414
    }
415

            
416
    private func symbolControlChip(
417
        systemImage: String,
418
        enabled: Bool,
419
        active: Bool,
420
        condensedLayout: Bool,
421
        showsLabel: Bool,
422
        label: String,
423
        accessibilityLabel: String,
424
        action: @escaping () -> Void
425
    ) -> some View {
426
        Button(action: {
427
            action()
428
        }) {
429
            Group {
430
                if showsLabel {
431
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 6 days ago
432
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
433
                        .padding(.horizontal, condensedLayout ? 10 : 12)
Bogdan Timofte authored 6 days ago
434
                        .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))
Bogdan Timofte authored a week ago
435
                } else {
436
                    Image(systemName: systemImage)
Bogdan Timofte authored 6 days ago
437
                        .font(.system(size: condensedLayout ? 15 : (isLargeDisplay ? 18 : 16), weight: .semibold))
438
                        .frame(
439
                            width: condensedLayout ? 34 : (isLargeDisplay ? 42 : 38),
440
                            height: condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)
441
                        )
Bogdan Timofte authored a week ago
442
                }
443
            }
444
                .background(
445
                    Capsule(style: .continuous)
446
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
447
                )
448
        }
449
        .buttonStyle(.plain)
450
        .foregroundColor(enabled ? .primary : .secondary)
451
        .opacity(enabled ? 1 : 0.55)
452
        .accessibilityLabel(accessibilityLabel)
453
    }
454

            
455
    private func resetBufferButton(condensedLayout: Bool) -> some View {
456
        Button(action: {
457
            showResetConfirmation = true
458
        }) {
459
            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
Bogdan Timofte authored 6 days ago
460
                .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
461
                .padding(.horizontal, condensedLayout ? 14 : 16)
Bogdan Timofte authored 6 days ago
462
                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
Bogdan Timofte authored a week ago
463
        }
464
        .buttonStyle(.plain)
465
        .foregroundColor(.white)
466
        .background(
467
            Capsule(style: .continuous)
468
                .fill(Color.red.opacity(0.8))
469
        )
470
        .fixedSize(horizontal: true, vertical: false)
471
        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
472
            Button("Reset series", role: .destructive) {
473
                measurements.resetSeries()
474
            }
475
            Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 weeks ago
476
        }
477
    }
478

            
Bogdan Timofte authored 6 days ago
479
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
480
        if isLargeDisplay {
481
            return .body.weight(.semibold)
482
        }
483
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
484
    }
485

            
486
    private func controlChipFont(condensedLayout: Bool) -> Font {
487
        if isLargeDisplay {
488
            return .callout.weight(.semibold)
489
        }
490
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
491
    }
492

            
Bogdan Timofte authored 2 weeks ago
493
    @ViewBuilder
494
    private func primaryAxisView(
495
        height: CGFloat,
Bogdan Timofte authored a week ago
496
        powerSeries: SeriesData,
497
        voltageSeries: SeriesData,
498
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
499
    ) -> some View {
500
        if displayPower {
501
            yAxisLabelsView(
502
                height: height,
503
                context: powerSeries.context,
Bogdan Timofte authored a week ago
504
                seriesKind: .power,
505
                measurementUnit: powerSeries.kind.unit,
506
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
507
            )
508
        } else if displayVoltage {
509
            yAxisLabelsView(
510
                height: height,
511
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
512
                seriesKind: .voltage,
513
                measurementUnit: voltageSeries.kind.unit,
514
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
515
            )
516
        } else if displayCurrent {
517
            yAxisLabelsView(
518
                height: height,
519
                context: currentSeries.context,
Bogdan Timofte authored a week ago
520
                seriesKind: .current,
521
                measurementUnit: currentSeries.kind.unit,
522
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
523
            )
524
        }
525
    }
526

            
527
    @ViewBuilder
528
    private func renderedChart(
Bogdan Timofte authored a week ago
529
        powerSeries: SeriesData,
530
        voltageSeries: SeriesData,
531
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
532
    ) -> some View {
533
        if self.displayPower {
534
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
535
                .opacity(0.72)
536
        } else {
537
            if self.displayVoltage {
538
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
539
                    .opacity(0.78)
540
            }
541
            if self.displayCurrent {
542
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
543
                    .opacity(0.78)
544
            }
545
        }
546
    }
547

            
548
    @ViewBuilder
549
    private func secondaryAxisView(
550
        height: CGFloat,
Bogdan Timofte authored a week ago
551
        powerSeries: SeriesData,
552
        voltageSeries: SeriesData,
553
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
554
    ) -> some View {
555
        if displayVoltage && displayCurrent {
556
            yAxisLabelsView(
557
                height: height,
558
                context: currentSeries.context,
Bogdan Timofte authored a week ago
559
                seriesKind: .current,
560
                measurementUnit: currentSeries.kind.unit,
561
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
562
            )
563
        } else {
564
            primaryAxisView(
565
                height: height,
566
                powerSeries: powerSeries,
567
                voltageSeries: voltageSeries,
568
                currentSeries: currentSeries
569
            )
Bogdan Timofte authored 2 weeks ago
570
        }
571
    }
Bogdan Timofte authored 2 weeks ago
572

            
573
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
574
        powerSeries: SeriesData,
575
        voltageSeries: SeriesData,
576
        currentSeries: SeriesData
577
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
578
        if displayPower {
Bogdan Timofte authored a week ago
579
            return powerSeries
Bogdan Timofte authored 2 weeks ago
580
        }
581
        if displayVoltage {
Bogdan Timofte authored a week ago
582
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
583
        }
584
        if displayCurrent {
Bogdan Timofte authored a week ago
585
            return currentSeries
Bogdan Timofte authored 2 weeks ago
586
        }
587
        return nil
588
    }
589

            
590
    private func series(
591
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
592
        kind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
593
        minimumYSpan: Double
Bogdan Timofte authored a week ago
594
    ) -> SeriesData {
Bogdan Timofte authored 2 weeks ago
595
        let points = measurement.points.filter { point in
596
            guard let timeRange else { return true }
597
            return timeRange.contains(point.timestamp)
598
        }
Bogdan Timofte authored a week ago
599
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
600
        let context = ChartContext()
Bogdan Timofte authored a week ago
601

            
602
        let autoBounds = automaticYBounds(
603
            for: samplePoints,
604
            minimumYSpan: minimumYSpan
605
        )
606
        let xBounds = xBounds(for: samplePoints)
607
        let lowerBound = resolvedLowerBound(
608
            for: kind,
609
            autoLowerBound: autoBounds.lowerBound
610
        )
611
        let upperBound = resolvedUpperBound(
612
            for: kind,
613
            lowerBound: lowerBound,
614
            autoUpperBound: autoBounds.upperBound,
615
            maximumSampleValue: samplePoints.map(\.value).max(),
616
            minimumYSpan: minimumYSpan
617
        )
618

            
619
        context.setBounds(
620
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
621
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
622
            yMin: CGFloat(lowerBound),
623
            yMax: CGFloat(upperBound)
624
        )
625

            
626
        return SeriesData(
627
            kind: kind,
628
            points: points,
629
            samplePoints: samplePoints,
630
            context: context,
631
            autoLowerBound: autoBounds.lowerBound,
632
            autoUpperBound: autoBounds.upperBound,
633
            maximumSampleValue: samplePoints.map(\.value).max()
634
        )
635
    }
636

            
637
    private var supportsSharedOrigin: Bool {
638
        displayVoltage && displayCurrent && !displayPower
639
    }
640

            
Bogdan Timofte authored 6 days ago
641
    private var minimumSharedScaleSpan: Double {
642
        max(minimumVoltageSpan, minimumCurrentSpan)
643
    }
644

            
Bogdan Timofte authored a week ago
645
    private var pinnedOriginIsZero: Bool {
646
        if useSharedOrigin && supportsSharedOrigin {
647
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
648
        }
Bogdan Timofte authored a week ago
649

            
650
        if displayPower {
651
            return pinOrigin && powerAxisOrigin == 0
652
        }
653

            
654
        let visibleOrigins = [
655
            displayVoltage ? voltageAxisOrigin : nil,
656
            displayCurrent ? currentAxisOrigin : nil
657
        ]
658
        .compactMap { $0 }
659

            
660
        guard !visibleOrigins.isEmpty else { return false }
661
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
662
    }
663

            
664
    private func toggleSharedOrigin(
665
        voltageSeries: SeriesData,
666
        currentSeries: SeriesData
667
    ) {
668
        guard supportsSharedOrigin else { return }
669

            
670
        if useSharedOrigin {
671
            useSharedOrigin = false
672
            return
673
        }
674

            
675
        captureCurrentOrigins(
676
            voltageSeries: voltageSeries,
677
            currentSeries: currentSeries
678
        )
679
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
680
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
681
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
682
        useSharedOrigin = true
683
        pinOrigin = true
684
    }
685

            
686
    private func togglePinnedOrigin(
687
        voltageSeries: SeriesData,
688
        currentSeries: SeriesData
689
    ) {
690
        if pinOrigin {
691
            pinOrigin = false
692
            return
693
        }
694

            
695
        captureCurrentOrigins(
696
            voltageSeries: voltageSeries,
697
            currentSeries: currentSeries
698
        )
699
        pinOrigin = true
700
    }
701

            
702
    private func setVisibleOriginsToZero() {
703
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
704
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
705
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
706
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
707
            voltageAxisOrigin = 0
708
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
709
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
710
        } else {
711
            if displayPower {
712
                powerAxisOrigin = 0
713
            }
714
            if displayVoltage {
715
                voltageAxisOrigin = 0
716
            }
717
            if displayCurrent {
718
                currentAxisOrigin = 0
719
            }
720
        }
721

            
722
        pinOrigin = true
723
    }
724

            
725
    private func captureCurrentOrigins(
726
        voltageSeries: SeriesData,
727
        currentSeries: SeriesData
728
    ) {
729
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
730
        voltageAxisOrigin = voltageSeries.autoLowerBound
731
        currentAxisOrigin = currentSeries.autoLowerBound
732
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
733
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
734
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
735
    }
736

            
737
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
738
        switch kind {
739
        case .power:
740
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
741
        case .voltage:
742
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
743
                return sharedAxisOrigin
744
            }
745
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
746
        case .current:
747
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
748
                return sharedAxisOrigin
749
            }
750
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
751
        }
752
    }
753

            
754
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
755
        measurement.points.filter { point in
756
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
757
        }
758
    }
759

            
760
    private func xBounds(
761
        for samplePoints: [Measurements.Measurement.Point]
762
    ) -> ClosedRange<Date> {
763
        if let timeRange {
764
            return timeRange
765
        }
766

            
767
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
768
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
769

            
770
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
771
            return lowerBound...upperBound
772
        }
773

            
774
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
775
    }
776

            
777
    private func automaticYBounds(
778
        for samplePoints: [Measurements.Measurement.Point],
779
        minimumYSpan: Double
780
    ) -> (lowerBound: Double, upperBound: Double) {
781
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
782

            
783
        guard
784
            let minimumSampleValue = samplePoints.map(\.value).min(),
785
            let maximumSampleValue = samplePoints.map(\.value).max()
786
        else {
787
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
788
        }
Bogdan Timofte authored a week ago
789

            
790
        var lowerBound = minimumSampleValue
791
        var upperBound = maximumSampleValue
792
        let currentSpan = upperBound - lowerBound
793

            
794
        if currentSpan < minimumYSpan {
795
            let expansion = (minimumYSpan - currentSpan) / 2
796
            lowerBound -= expansion
797
            upperBound += expansion
798
        }
799

            
800
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
801
            let shift = -negativeAllowance - lowerBound
802
            lowerBound += shift
803
            upperBound += shift
804
        }
805

            
806
        let snappedLowerBound = snappedOriginValue(lowerBound)
807
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
808
        return (snappedLowerBound, resolvedUpperBound)
809
    }
810

            
811
    private func resolvedLowerBound(
812
        for kind: SeriesKind,
813
        autoLowerBound: Double
814
    ) -> Double {
815
        guard pinOrigin else { return autoLowerBound }
816

            
817
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
818
            return sharedAxisOrigin
819
        }
820

            
821
        switch kind {
822
        case .power:
823
            return powerAxisOrigin
824
        case .voltage:
825
            return voltageAxisOrigin
826
        case .current:
827
            return currentAxisOrigin
828
        }
829
    }
830

            
831
    private func resolvedUpperBound(
832
        for kind: SeriesKind,
833
        lowerBound: Double,
834
        autoUpperBound: Double,
835
        maximumSampleValue: Double?,
836
        minimumYSpan: Double
837
    ) -> Double {
838
        guard pinOrigin else {
839
            return autoUpperBound
840
        }
841

            
Bogdan Timofte authored 6 days ago
842
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
843
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
844
        }
845

            
Bogdan Timofte authored a week ago
846
        return max(
847
            maximumSampleValue ?? lowerBound,
848
            lowerBound + minimumYSpan,
849
            autoUpperBound
850
        )
851
    }
852

            
Bogdan Timofte authored 6 days ago
853
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
854
        let baseline = displayedLowerBoundForSeries(kind)
855
        let proposedOrigin = snappedOriginValue(baseline + delta)
856

            
857
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
858
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
859
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
860
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
861
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
862
        } else {
863
            switch kind {
864
            case .power:
865
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
866
            case .voltage:
867
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
868
            case .current:
869
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
870
            }
871
        }
872

            
873
        pinOrigin = true
874
    }
875

            
Bogdan Timofte authored 6 days ago
876
    private func clearOriginOffset(for kind: SeriesKind) {
877
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
878
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
879
            sharedAxisOrigin = 0
880
            sharedAxisUpperBound = currentSpan
881
            ensureSharedScaleSpan()
882
            voltageAxisOrigin = 0
883
            currentAxisOrigin = 0
884
        } else {
885
            switch kind {
886
            case .power:
887
                powerAxisOrigin = 0
888
            case .voltage:
889
                voltageAxisOrigin = 0
890
            case .current:
891
                currentAxisOrigin = 0
892
            }
893
        }
894

            
895
        pinOrigin = true
896
    }
897

            
898
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
899
        guard totalHeight > 1 else { return }
900

            
901
        let normalized = max(0, min(1, locationY / totalHeight))
902
        if normalized < (1.0 / 3.0) {
903
            applyOriginDelta(-1, kind: kind)
904
        } else if normalized < (2.0 / 3.0) {
905
            clearOriginOffset(for: kind)
906
        } else {
907
            applyOriginDelta(1, kind: kind)
908
        }
909
    }
910

            
Bogdan Timofte authored a week ago
911
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
912
        switch kind {
913
        case .power:
914
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
915
        case .voltage:
916
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
917
        case .current:
918
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
919
        }
920
    }
921

            
922
    private func maximumVisibleSharedOrigin() -> Double {
923
        min(
924
            maximumVisibleOrigin(for: .voltage),
925
            maximumVisibleOrigin(for: .current)
926
        )
927
    }
928

            
Bogdan Timofte authored 6 days ago
929
    private func ensureSharedScaleSpan() {
930
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
931
    }
932

            
Bogdan Timofte authored a week ago
933
    private func snappedOriginValue(_ value: Double) -> Double {
934
        if value >= 0 {
935
            return value.rounded(.down)
936
        }
937

            
938
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
939
    }
Bogdan Timofte authored 2 weeks ago
940

            
941
    private func yGuidePosition(
942
        for labelIndex: Int,
943
        context: ChartContext,
944
        height: CGFloat
945
    ) -> CGFloat {
946
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
947
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
948
        return context.placeInRect(point: anchorPoint).y * height
949
    }
950

            
951
    private func xGuidePosition(
952
        for labelIndex: Int,
953
        context: ChartContext,
954
        width: CGFloat
955
    ) -> CGFloat {
956
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
957
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
958
        return context.placeInRect(point: anchorPoint).x * width
959
    }
Bogdan Timofte authored 2 weeks ago
960

            
961
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
962
    fileprivate func xAxisLabelsView(
963
        context: ChartContext
964
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
965
        var timeFormat: String?
966
        switch context.size.width {
967
        case 0..<3600: timeFormat = "HH:mm:ss"
968
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
969
        default: timeFormat = "E HH:mm"
970
        }
971
        let labels = (1...xLabels).map {
972
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
973
        }
Bogdan Timofte authored 2 weeks ago
974

            
975
        return HStack(spacing: chartSectionSpacing) {
976
            Color.clear
977
                .frame(width: axisColumnWidth)
978

            
979
            GeometryReader { geometry in
980
                let labelWidth = max(
981
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
982
                    1
983
                )
984

            
985
                ZStack(alignment: .topLeading) {
986
                    Path { path in
987
                        for labelIndex in 1...self.xLabels {
988
                            let x = xGuidePosition(
989
                                for: labelIndex,
990
                                context: context,
991
                                width: geometry.size.width
992
                            )
993
                            path.move(to: CGPoint(x: x, y: 0))
994
                            path.addLine(to: CGPoint(x: x, y: 6))
995
                        }
996
                    }
997
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
998

            
999
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1000
                        let labelIndex = item.offset + 1
1001
                        let centerX = xGuidePosition(
1002
                            for: labelIndex,
1003
                            context: context,
1004
                            width: geometry.size.width
1005
                        )
1006

            
1007
                        Text(item.element)
Bogdan Timofte authored 6 days ago
1008
                            .font((isLargeDisplay ? Font.callout : .caption).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
1009
                            .monospacedDigit()
1010
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1011
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1012
                            .frame(width: labelWidth)
1013
                            .position(
1014
                                x: centerX,
1015
                                y: geometry.size.height * 0.7
1016
                            )
Bogdan Timofte authored 2 weeks ago
1017
                    }
1018
                }
Bogdan Timofte authored 2 weeks ago
1019
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1020
            }
Bogdan Timofte authored 2 weeks ago
1021

            
1022
            Color.clear
1023
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1024
        }
1025
    }
1026

            
Bogdan Timofte authored a week ago
1027
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1028
        height: CGFloat,
1029
        context: ChartContext,
Bogdan Timofte authored a week ago
1030
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1031
        measurementUnit: String,
1032
        tint: Color
1033
    ) -> some View {
1034
        GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1035
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1036
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1037
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1038

            
Bogdan Timofte authored 2 weeks ago
1039
            ZStack(alignment: .top) {
1040
                ForEach(0..<yLabels, id: \.self) { row in
1041
                    let labelIndex = yLabels - row
1042

            
1043
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1044
                        .font((isLargeDisplay ? Font.callout : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
1045
                        .monospacedDigit()
1046
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1047
                        .minimumScaleFactor(0.8)
1048
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1049
                        .position(
1050
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1051
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1052
                                for: labelIndex,
1053
                                context: context,
Bogdan Timofte authored 6 days ago
1054
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1055
                            )
1056
                        )
Bogdan Timofte authored 2 weeks ago
1057
                }
Bogdan Timofte authored 2 weeks ago
1058

            
Bogdan Timofte authored 2 weeks ago
1059
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1060
                    .font((isLargeDisplay ? Font.footnote : .caption2).weight(.bold))
Bogdan Timofte authored 2 weeks ago
1061
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1062
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1063
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1064
                    .background(
1065
                        Capsule(style: .continuous)
1066
                            .fill(tint.opacity(0.14))
1067
                    )
Bogdan Timofte authored 6 days ago
1068
                    .padding(.top, 8)
1069

            
1070
                VStack {
1071
                    Spacer(minLength: 0)
Bogdan Timofte authored a week ago
1072

            
Bogdan Timofte authored 6 days ago
1073
                    HStack(spacing: 6) {
1074
                        Text("Y \(Int(displayedLowerBoundForSeries(seriesKind)))")
1075
                            .font((isLargeDisplay ? Font.callout : .caption2).weight(.semibold))
1076
                            .foregroundColor(.secondary)
1077
                        Spacer(minLength: 0)
1078
                    }
1079
                    .padding(.horizontal, 6)
1080
                    .padding(.vertical, 6)
1081
                    .background(
1082
                        Capsule(style: .continuous)
1083
                            .fill(Color.primary.opacity(0.06))
1084
                    )
1085
                    .padding(.horizontal, 4)
1086
                    .padding(.bottom, 4)
1087
                }
Bogdan Timofte authored 2 weeks ago
1088
            }
1089
        }
Bogdan Timofte authored 2 weeks ago
1090
        .frame(height: height)
1091
        .background(
1092
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1093
                .fill(tint.opacity(0.12))
1094
        )
1095
        .overlay(
1096
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1097
                .stroke(tint.opacity(0.20), lineWidth: 1)
1098
        )
Bogdan Timofte authored a week ago
1099
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1100
        .gesture(
Bogdan Timofte authored 6 days ago
1101
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1102
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1103
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1104
                }
1105
        )
Bogdan Timofte authored 2 weeks ago
1106
    }
1107

            
Bogdan Timofte authored 2 weeks ago
1108
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1109
        GeometryReader { geometry in
1110
            Path { path in
Bogdan Timofte authored 2 weeks ago
1111
                for labelIndex in 1...self.yLabels {
1112
                    let y = yGuidePosition(
1113
                        for: labelIndex,
1114
                        context: context,
1115
                        height: geometry.size.height
1116
                    )
1117
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1118
                }
Bogdan Timofte authored 2 weeks ago
1119
            }
1120
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1121
        }
1122
    }
1123

            
Bogdan Timofte authored 2 weeks ago
1124
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1125
        GeometryReader { geometry in
1126
            Path { path in
1127

            
Bogdan Timofte authored 2 weeks ago
1128
                for labelIndex in 2..<self.xLabels {
1129
                    let x = xGuidePosition(
1130
                        for: labelIndex,
1131
                        context: context,
1132
                        width: geometry.size.width
1133
                    )
1134
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1135
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1136
                }
Bogdan Timofte authored 2 weeks ago
1137
            }
1138
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1139
        }
1140
    }
Bogdan Timofte authored a week ago
1141

            
1142
    fileprivate func discontinuityMarkers(
1143
        points: [Measurements.Measurement.Point],
1144
        context: ChartContext
1145
    ) -> some View {
1146
        GeometryReader { geometry in
1147
            Path { path in
1148
                for point in points where point.isDiscontinuity {
1149
                    let markerX = context.placeInRect(
1150
                        point: CGPoint(
1151
                            x: point.timestamp.timeIntervalSince1970,
1152
                            y: context.origin.y
1153
                        )
1154
                    ).x * geometry.size.width
1155
                    path.move(to: CGPoint(x: markerX, y: 0))
1156
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1157
                }
1158
            }
1159
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1160
        }
1161
    }
Bogdan Timofte authored 2 weeks ago
1162

            
1163
}
1164

            
1165
struct Chart : View {
1166

            
Bogdan Timofte authored 2 weeks ago
1167
    let points: [Measurements.Measurement.Point]
1168
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1169
    var areaChart: Bool = false
1170
    var strokeColor: Color = .black
1171

            
1172
    var body : some View {
1173
        GeometryReader { geometry in
1174
            if self.areaChart {
1175
                self.path( geometry: geometry )
1176
                    .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)))
1177
            } else {
1178
                self.path( geometry: geometry )
1179
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1180
            }
1181
        }
1182
    }
1183

            
1184
    fileprivate func path(geometry: GeometryProxy) -> Path {
1185
        return Path { path in
Bogdan Timofte authored a week ago
1186
            var firstSample: Measurements.Measurement.Point?
1187
            var lastSample: Measurements.Measurement.Point?
1188
            var needsMove = true
1189

            
1190
            for point in points {
1191
                if point.isDiscontinuity {
1192
                    needsMove = true
1193
                    continue
1194
                }
1195

            
1196
                let item = context.placeInRect(point: point.point())
1197
                let renderedPoint = CGPoint(
1198
                    x: item.x * geometry.size.width,
1199
                    y: item.y * geometry.size.height
1200
                )
1201

            
1202
                if firstSample == nil {
1203
                    firstSample = point
1204
                }
1205
                lastSample = point
1206

            
1207
                if needsMove {
1208
                    path.move(to: renderedPoint)
1209
                    needsMove = false
1210
                } else {
1211
                    path.addLine(to: renderedPoint)
1212
                }
Bogdan Timofte authored 2 weeks ago
1213
            }
Bogdan Timofte authored a week ago
1214

            
1215
            if self.areaChart, let firstSample, let lastSample {
1216
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1217
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1218
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1219
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1220
                // MARK: Nu e nevoie. Fill inchide automat calea
1221
                // path.closeSubpath()
1222
            }
1223
        }
1224
    }
1225

            
1226
}