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

            
101
    private var plotSectionHeight: CGFloat {
102
        if availableSize == .zero {
103
            return compactLayout ? 260 : 340
104
        }
105

            
106
        if compactLayout {
107
            return min(max(availableSize.height * 0.36, 240), 300)
108
        }
109

            
110
        return min(max(availableSize.height * 0.5, 300), 440)
111
    }
112

            
113
    private var stackedToolbarLayout: Bool {
114
        if availableSize.width > 0 {
115
            return availableSize.width < 640
116
        }
117

            
118
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
119
    }
120

            
121
    private var showsLabeledOriginControls: Bool {
122
        !compactLayout && !stackedToolbarLayout
123
    }
124

            
Bogdan Timofte authored 6 days ago
125
    private var isLargeDisplay: Bool {
126
        if availableSize.width > 0 {
127
            return availableSize.width >= 900 || availableSize.height >= 700
128
        }
129
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
130
    }
131

            
132
    private var chartBaseFont: Font {
133
        isLargeDisplay ? .callout : .footnote
134
    }
135

            
Bogdan Timofte authored 2 weeks ago
136
    var body: some View {
Bogdan Timofte authored a week ago
137
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
138
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
139
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
140
        let primarySeries = displayedPrimarySeries(
141
            powerSeries: powerSeries,
142
            voltageSeries: voltageSeries,
143
            currentSeries: currentSeries
144
        )
145

            
Bogdan Timofte authored 2 weeks ago
146
        Group {
Bogdan Timofte authored 2 weeks ago
147
            if let primarySeries {
148
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
149
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
150

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

            
154
                        VStack(spacing: 6) {
155
                            HStack(spacing: chartSectionSpacing) {
156
                                primaryAxisView(
157
                                    height: plotHeight,
158
                                    powerSeries: powerSeries,
159
                                    voltageSeries: voltageSeries,
160
                                    currentSeries: currentSeries
161
                                )
162
                                .frame(width: axisColumnWidth, height: plotHeight)
163

            
164
                                ZStack {
165
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
166
                                        .fill(Color.primary.opacity(0.05))
167

            
168
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
169
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
170

            
171
                                    horizontalGuides(context: primarySeries.context)
172
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
173
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
174
                                    renderedChart(
175
                                        powerSeries: powerSeries,
176
                                        voltageSeries: voltageSeries,
177
                                        currentSeries: currentSeries
178
                                    )
Bogdan Timofte authored 2 weeks ago
179
                                }
Bogdan Timofte authored 2 weeks ago
180
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
181
                                .frame(maxWidth: .infinity)
182
                                .frame(height: plotHeight)
183

            
184
                                secondaryAxisView(
185
                                    height: plotHeight,
186
                                    powerSeries: powerSeries,
187
                                    voltageSeries: voltageSeries,
188
                                    currentSeries: currentSeries
189
                                )
190
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
191
                            }
Bogdan Timofte authored 6 days ago
192
                            .overlay(alignment: .bottom) {
193
                                scaleControlsPill(
194
                                    voltageSeries: voltageSeries,
195
                                    currentSeries: currentSeries
196
                                )
197
                                .padding(.bottom, compactLayout ? 6 : 10)
198
                            }
Bogdan Timofte authored 2 weeks ago
199

            
200
                            xAxisLabelsView(context: primarySeries.context)
201
                            .frame(height: xAxisHeight)
Bogdan Timofte authored 2 weeks ago
202
                        }
Bogdan Timofte authored 2 weeks ago
203
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
204
                    }
Bogdan Timofte authored a week ago
205
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 weeks ago
206
                }
Bogdan Timofte authored 2 weeks ago
207
            } else {
208
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
209
                    chartToggleBar()
Bogdan Timofte authored a week ago
210
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 weeks ago
211
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
212
                }
213
            }
Bogdan Timofte authored 2 weeks ago
214
        }
Bogdan Timofte authored 6 days ago
215
        .font(chartBaseFont)
Bogdan Timofte authored 2 weeks ago
216
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a week ago
217
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
218
            guard timeRange == nil else { return }
219
            chartNow = now
220
        }
Bogdan Timofte authored 2 weeks ago
221
    }
222

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

            
Bogdan Timofte authored 6 days ago
227
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
228
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
229
        }
230
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
231
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
232
        .background(
233
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
234
                .fill(Color.primary.opacity(0.045))
235
        )
236
        .overlay(
237
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
238
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
239
        )
Bogdan Timofte authored a week ago
240

            
Bogdan Timofte authored 6 days ago
241
        return Group {
Bogdan Timofte authored a week ago
242
            if stackedToolbarLayout {
Bogdan Timofte authored 6 days ago
243
                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
244
                    controlsPanel
245
                    HStack {
246
                        Spacer(minLength: 0)
247
                        resetBufferButton(condensedLayout: condensedLayout)
248
                    }
Bogdan Timofte authored 2 weeks ago
249
                }
Bogdan Timofte authored a week ago
250
            } else {
Bogdan Timofte authored 6 days ago
251
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
252
                    controlsPanel
Bogdan Timofte authored a week ago
253
                    Spacer(minLength: 0)
254
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
255
                }
Bogdan Timofte authored a week ago
256
            }
257
        }
258
        .frame(maxWidth: .infinity, alignment: .leading)
259
    }
260

            
Bogdan Timofte authored 6 days ago
261
    private var shouldFloatScaleControlsOverChart: Bool {
262
        #if os(iOS)
263
        if availableSize.width > 0, availableSize.height > 0 {
264
            return availableSize.width > availableSize.height
265
        }
266
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
267
        #else
268
        return false
269
        #endif
270
    }
271

            
272
    private func scaleControlsPill(
273
        voltageSeries: SeriesData,
274
        currentSeries: SeriesData
275
    ) -> some View {
276
        let condensedLayout = compactLayout || verticalSizeClass == .compact
277

            
278
        return originControlsRow(
279
            voltageSeries: voltageSeries,
280
            currentSeries: currentSeries,
281
            condensedLayout: condensedLayout,
282
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
283
        )
284
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
285
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
286
        .background(
287
            Capsule(style: .continuous)
288
                .fill(shouldFloatScaleControlsOverChart ? Color.clear : Color.primary.opacity(0.08))
289
        )
290
        .overlay(
291
            Capsule(style: .continuous)
292
                .stroke(
293
                    shouldFloatScaleControlsOverChart ? Color.clear : Color.secondary.opacity(0.18),
294
                    lineWidth: 1
295
                )
296
        )
297
    }
298

            
Bogdan Timofte authored a week ago
299
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
300
        HStack(spacing: condensedLayout ? 6 : 8) {
301
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
302
                displayVoltage.toggle()
303
                if displayVoltage {
304
                    displayPower = false
305
                }
306
            }
307

            
308
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
309
                displayCurrent.toggle()
310
                if displayCurrent {
311
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
312
                }
Bogdan Timofte authored a week ago
313
            }
314

            
315
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
316
                displayPower.toggle()
317
                if displayPower {
318
                    displayCurrent = false
319
                    displayVoltage = false
320
                }
321
            }
322
        }
323
    }
324

            
325
    private func originControlsRow(
326
        voltageSeries: SeriesData,
327
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
328
        condensedLayout: Bool,
329
        showsLabel: Bool
Bogdan Timofte authored a week ago
330
    ) -> some View {
331
        HStack(spacing: condensedLayout ? 8 : 10) {
332
            symbolControlChip(
333
                systemImage: "equal.circle",
334
                enabled: supportsSharedOrigin,
335
                active: useSharedOrigin && supportsSharedOrigin,
336
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
337
                showsLabel: showsLabel,
338
                label: "Match Y Scale",
339
                accessibilityLabel: "Match Y scale"
Bogdan Timofte authored a week ago
340
            ) {
341
                toggleSharedOrigin(
342
                    voltageSeries: voltageSeries,
343
                    currentSeries: currentSeries
344
                )
345
            }
346

            
347
            symbolControlChip(
348
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
349
                enabled: true,
350
                active: pinOrigin,
351
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
352
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
353
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
354
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
355
            ) {
356
                togglePinnedOrigin(
357
                    voltageSeries: voltageSeries,
358
                    currentSeries: currentSeries
359
                )
360
            }
361

            
362
            symbolControlChip(
363
                systemImage: "0.circle",
364
                enabled: true,
365
                active: pinnedOriginIsZero,
366
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
367
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
368
                label: "Origin 0",
369
                accessibilityLabel: "Set origin to zero"
370
            ) {
371
                setVisibleOriginsToZero()
372
            }
Bogdan Timofte authored 6 days ago
373

            
Bogdan Timofte authored a week ago
374
        }
375
    }
376

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

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

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

            
Bogdan Timofte authored 6 days ago
468
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
469
        if isLargeDisplay {
470
            return .body.weight(.semibold)
471
        }
472
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
473
    }
474

            
475
    private func controlChipFont(condensedLayout: Bool) -> Font {
476
        if isLargeDisplay {
477
            return .callout.weight(.semibold)
478
        }
479
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
480
    }
481

            
Bogdan Timofte authored 2 weeks ago
482
    @ViewBuilder
483
    private func primaryAxisView(
484
        height: CGFloat,
Bogdan Timofte authored a week ago
485
        powerSeries: SeriesData,
486
        voltageSeries: SeriesData,
487
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
488
    ) -> some View {
489
        if displayPower {
490
            yAxisLabelsView(
491
                height: height,
492
                context: powerSeries.context,
Bogdan Timofte authored a week ago
493
                seriesKind: .power,
494
                measurementUnit: powerSeries.kind.unit,
495
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
496
            )
497
        } else if displayVoltage {
498
            yAxisLabelsView(
499
                height: height,
500
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
501
                seriesKind: .voltage,
502
                measurementUnit: voltageSeries.kind.unit,
503
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
504
            )
505
        } else if displayCurrent {
506
            yAxisLabelsView(
507
                height: height,
508
                context: currentSeries.context,
Bogdan Timofte authored a week ago
509
                seriesKind: .current,
510
                measurementUnit: currentSeries.kind.unit,
511
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
512
            )
513
        }
514
    }
515

            
516
    @ViewBuilder
517
    private func renderedChart(
Bogdan Timofte authored a week ago
518
        powerSeries: SeriesData,
519
        voltageSeries: SeriesData,
520
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
521
    ) -> some View {
522
        if self.displayPower {
523
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
524
                .opacity(0.72)
525
        } else {
526
            if self.displayVoltage {
527
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
528
                    .opacity(0.78)
529
            }
530
            if self.displayCurrent {
531
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
532
                    .opacity(0.78)
533
            }
534
        }
535
    }
536

            
537
    @ViewBuilder
538
    private func secondaryAxisView(
539
        height: CGFloat,
Bogdan Timofte authored a week ago
540
        powerSeries: SeriesData,
541
        voltageSeries: SeriesData,
542
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
543
    ) -> some View {
544
        if displayVoltage && displayCurrent {
545
            yAxisLabelsView(
546
                height: height,
547
                context: currentSeries.context,
Bogdan Timofte authored a week ago
548
                seriesKind: .current,
549
                measurementUnit: currentSeries.kind.unit,
550
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
551
            )
552
        } else {
553
            primaryAxisView(
554
                height: height,
555
                powerSeries: powerSeries,
556
                voltageSeries: voltageSeries,
557
                currentSeries: currentSeries
558
            )
Bogdan Timofte authored 2 weeks ago
559
        }
560
    }
Bogdan Timofte authored 2 weeks ago
561

            
562
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
563
        powerSeries: SeriesData,
564
        voltageSeries: SeriesData,
565
        currentSeries: SeriesData
566
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
567
        if displayPower {
Bogdan Timofte authored a week ago
568
            return powerSeries
Bogdan Timofte authored 2 weeks ago
569
        }
570
        if displayVoltage {
Bogdan Timofte authored a week ago
571
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
572
        }
573
        if displayCurrent {
Bogdan Timofte authored a week ago
574
            return currentSeries
Bogdan Timofte authored 2 weeks ago
575
        }
576
        return nil
577
    }
578

            
579
    private func series(
580
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
581
        kind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
582
        minimumYSpan: Double
Bogdan Timofte authored a week ago
583
    ) -> SeriesData {
Bogdan Timofte authored 2 weeks ago
584
        let points = measurement.points.filter { point in
585
            guard let timeRange else { return true }
586
            return timeRange.contains(point.timestamp)
587
        }
Bogdan Timofte authored a week ago
588
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
589
        let context = ChartContext()
Bogdan Timofte authored a week ago
590

            
591
        let autoBounds = automaticYBounds(
592
            for: samplePoints,
593
            minimumYSpan: minimumYSpan
594
        )
595
        let xBounds = xBounds(for: samplePoints)
596
        let lowerBound = resolvedLowerBound(
597
            for: kind,
598
            autoLowerBound: autoBounds.lowerBound
599
        )
600
        let upperBound = resolvedUpperBound(
601
            for: kind,
602
            lowerBound: lowerBound,
603
            autoUpperBound: autoBounds.upperBound,
604
            maximumSampleValue: samplePoints.map(\.value).max(),
605
            minimumYSpan: minimumYSpan
606
        )
607

            
608
        context.setBounds(
609
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
610
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
611
            yMin: CGFloat(lowerBound),
612
            yMax: CGFloat(upperBound)
613
        )
614

            
615
        return SeriesData(
616
            kind: kind,
617
            points: points,
618
            samplePoints: samplePoints,
619
            context: context,
620
            autoLowerBound: autoBounds.lowerBound,
621
            autoUpperBound: autoBounds.upperBound,
622
            maximumSampleValue: samplePoints.map(\.value).max()
623
        )
624
    }
625

            
626
    private var supportsSharedOrigin: Bool {
627
        displayVoltage && displayCurrent && !displayPower
628
    }
629

            
Bogdan Timofte authored 6 days ago
630
    private var minimumSharedScaleSpan: Double {
631
        max(minimumVoltageSpan, minimumCurrentSpan)
632
    }
633

            
Bogdan Timofte authored a week ago
634
    private var pinnedOriginIsZero: Bool {
635
        if useSharedOrigin && supportsSharedOrigin {
636
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
637
        }
Bogdan Timofte authored a week ago
638

            
639
        if displayPower {
640
            return pinOrigin && powerAxisOrigin == 0
641
        }
642

            
643
        let visibleOrigins = [
644
            displayVoltage ? voltageAxisOrigin : nil,
645
            displayCurrent ? currentAxisOrigin : nil
646
        ]
647
        .compactMap { $0 }
648

            
649
        guard !visibleOrigins.isEmpty else { return false }
650
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
651
    }
652

            
653
    private func toggleSharedOrigin(
654
        voltageSeries: SeriesData,
655
        currentSeries: SeriesData
656
    ) {
657
        guard supportsSharedOrigin else { return }
658

            
659
        if useSharedOrigin {
660
            useSharedOrigin = false
661
            return
662
        }
663

            
664
        captureCurrentOrigins(
665
            voltageSeries: voltageSeries,
666
            currentSeries: currentSeries
667
        )
668
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
669
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
670
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
671
        useSharedOrigin = true
672
        pinOrigin = true
673
    }
674

            
675
    private func togglePinnedOrigin(
676
        voltageSeries: SeriesData,
677
        currentSeries: SeriesData
678
    ) {
679
        if pinOrigin {
680
            pinOrigin = false
681
            return
682
        }
683

            
684
        captureCurrentOrigins(
685
            voltageSeries: voltageSeries,
686
            currentSeries: currentSeries
687
        )
688
        pinOrigin = true
689
    }
690

            
691
    private func setVisibleOriginsToZero() {
692
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
693
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
694
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
695
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
696
            voltageAxisOrigin = 0
697
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
698
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
699
        } else {
700
            if displayPower {
701
                powerAxisOrigin = 0
702
            }
703
            if displayVoltage {
704
                voltageAxisOrigin = 0
705
            }
706
            if displayCurrent {
707
                currentAxisOrigin = 0
708
            }
709
        }
710

            
711
        pinOrigin = true
712
    }
713

            
714
    private func captureCurrentOrigins(
715
        voltageSeries: SeriesData,
716
        currentSeries: SeriesData
717
    ) {
718
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
719
        voltageAxisOrigin = voltageSeries.autoLowerBound
720
        currentAxisOrigin = currentSeries.autoLowerBound
721
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
722
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
723
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
724
    }
725

            
726
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
727
        switch kind {
728
        case .power:
729
            return pinOrigin ? powerAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.power), minimumYSpan: minimumPowerSpan).lowerBound
730
        case .voltage:
731
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
732
                return sharedAxisOrigin
733
            }
734
            return pinOrigin ? voltageAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.voltage), minimumYSpan: minimumVoltageSpan).lowerBound
735
        case .current:
736
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
737
                return sharedAxisOrigin
738
            }
739
            return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
740
        }
741
    }
742

            
743
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
744
        measurement.points.filter { point in
745
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
746
        }
747
    }
748

            
749
    private func xBounds(
750
        for samplePoints: [Measurements.Measurement.Point]
751
    ) -> ClosedRange<Date> {
752
        if let timeRange {
753
            return timeRange
754
        }
755

            
756
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
757
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
758

            
759
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
760
            return lowerBound...upperBound
761
        }
762

            
763
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
764
    }
765

            
766
    private func automaticYBounds(
767
        for samplePoints: [Measurements.Measurement.Point],
768
        minimumYSpan: Double
769
    ) -> (lowerBound: Double, upperBound: Double) {
770
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
771

            
772
        guard
773
            let minimumSampleValue = samplePoints.map(\.value).min(),
774
            let maximumSampleValue = samplePoints.map(\.value).max()
775
        else {
776
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
777
        }
Bogdan Timofte authored a week ago
778

            
779
        var lowerBound = minimumSampleValue
780
        var upperBound = maximumSampleValue
781
        let currentSpan = upperBound - lowerBound
782

            
783
        if currentSpan < minimumYSpan {
784
            let expansion = (minimumYSpan - currentSpan) / 2
785
            lowerBound -= expansion
786
            upperBound += expansion
787
        }
788

            
789
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
790
            let shift = -negativeAllowance - lowerBound
791
            lowerBound += shift
792
            upperBound += shift
793
        }
794

            
795
        let snappedLowerBound = snappedOriginValue(lowerBound)
796
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
797
        return (snappedLowerBound, resolvedUpperBound)
798
    }
799

            
800
    private func resolvedLowerBound(
801
        for kind: SeriesKind,
802
        autoLowerBound: Double
803
    ) -> Double {
804
        guard pinOrigin else { return autoLowerBound }
805

            
806
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
807
            return sharedAxisOrigin
808
        }
809

            
810
        switch kind {
811
        case .power:
812
            return powerAxisOrigin
813
        case .voltage:
814
            return voltageAxisOrigin
815
        case .current:
816
            return currentAxisOrigin
817
        }
818
    }
819

            
820
    private func resolvedUpperBound(
821
        for kind: SeriesKind,
822
        lowerBound: Double,
823
        autoUpperBound: Double,
824
        maximumSampleValue: Double?,
825
        minimumYSpan: Double
826
    ) -> Double {
827
        guard pinOrigin else {
828
            return autoUpperBound
829
        }
830

            
Bogdan Timofte authored 6 days ago
831
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
832
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
833
        }
834

            
Bogdan Timofte authored a week ago
835
        return max(
836
            maximumSampleValue ?? lowerBound,
837
            lowerBound + minimumYSpan,
838
            autoUpperBound
839
        )
840
    }
841

            
Bogdan Timofte authored 6 days ago
842
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
843
        let baseline = displayedLowerBoundForSeries(kind)
844
        let proposedOrigin = snappedOriginValue(baseline + delta)
845

            
846
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
847
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
848
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
849
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
850
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
851
        } else {
852
            switch kind {
853
            case .power:
854
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
855
            case .voltage:
856
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
857
            case .current:
858
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
859
            }
860
        }
861

            
862
        pinOrigin = true
863
    }
864

            
Bogdan Timofte authored 6 days ago
865
    private func clearOriginOffset(for kind: SeriesKind) {
866
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
867
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
868
            sharedAxisOrigin = 0
869
            sharedAxisUpperBound = currentSpan
870
            ensureSharedScaleSpan()
871
            voltageAxisOrigin = 0
872
            currentAxisOrigin = 0
873
        } else {
874
            switch kind {
875
            case .power:
876
                powerAxisOrigin = 0
877
            case .voltage:
878
                voltageAxisOrigin = 0
879
            case .current:
880
                currentAxisOrigin = 0
881
            }
882
        }
883

            
884
        pinOrigin = true
885
    }
886

            
887
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
888
        guard totalHeight > 1 else { return }
889

            
890
        let normalized = max(0, min(1, locationY / totalHeight))
891
        if normalized < (1.0 / 3.0) {
892
            applyOriginDelta(-1, kind: kind)
893
        } else if normalized < (2.0 / 3.0) {
894
            clearOriginOffset(for: kind)
895
        } else {
896
            applyOriginDelta(1, kind: kind)
897
        }
898
    }
899

            
Bogdan Timofte authored a week ago
900
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
901
        switch kind {
902
        case .power:
903
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
904
        case .voltage:
905
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
906
        case .current:
907
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
908
        }
909
    }
910

            
911
    private func maximumVisibleSharedOrigin() -> Double {
912
        min(
913
            maximumVisibleOrigin(for: .voltage),
914
            maximumVisibleOrigin(for: .current)
915
        )
916
    }
917

            
Bogdan Timofte authored 6 days ago
918
    private func ensureSharedScaleSpan() {
919
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
920
    }
921

            
Bogdan Timofte authored a week ago
922
    private func snappedOriginValue(_ value: Double) -> Double {
923
        if value >= 0 {
924
            return value.rounded(.down)
925
        }
926

            
927
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
928
    }
Bogdan Timofte authored 2 weeks ago
929

            
930
    private func yGuidePosition(
931
        for labelIndex: Int,
932
        context: ChartContext,
933
        height: CGFloat
934
    ) -> CGFloat {
935
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
936
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
937
        return context.placeInRect(point: anchorPoint).y * height
938
    }
939

            
940
    private func xGuidePosition(
941
        for labelIndex: Int,
942
        context: ChartContext,
943
        width: CGFloat
944
    ) -> CGFloat {
945
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
946
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
947
        return context.placeInRect(point: anchorPoint).x * width
948
    }
Bogdan Timofte authored 2 weeks ago
949

            
950
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
951
    fileprivate func xAxisLabelsView(
952
        context: ChartContext
953
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
954
        var timeFormat: String?
955
        switch context.size.width {
956
        case 0..<3600: timeFormat = "HH:mm:ss"
957
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
958
        default: timeFormat = "E HH:mm"
959
        }
960
        let labels = (1...xLabels).map {
961
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
962
        }
Bogdan Timofte authored 2 weeks ago
963

            
964
        return HStack(spacing: chartSectionSpacing) {
965
            Color.clear
966
                .frame(width: axisColumnWidth)
967

            
968
            GeometryReader { geometry in
969
                let labelWidth = max(
970
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
971
                    1
972
                )
973

            
974
                ZStack(alignment: .topLeading) {
975
                    Path { path in
976
                        for labelIndex in 1...self.xLabels {
977
                            let x = xGuidePosition(
978
                                for: labelIndex,
979
                                context: context,
980
                                width: geometry.size.width
981
                            )
982
                            path.move(to: CGPoint(x: x, y: 0))
983
                            path.addLine(to: CGPoint(x: x, y: 6))
984
                        }
985
                    }
986
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
987

            
988
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
989
                        let labelIndex = item.offset + 1
990
                        let centerX = xGuidePosition(
991
                            for: labelIndex,
992
                            context: context,
993
                            width: geometry.size.width
994
                        )
995

            
996
                        Text(item.element)
Bogdan Timofte authored 6 days ago
997
                            .font((isLargeDisplay ? Font.callout : .caption).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
998
                            .monospacedDigit()
999
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1000
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1001
                            .frame(width: labelWidth)
1002
                            .position(
1003
                                x: centerX,
1004
                                y: geometry.size.height * 0.7
1005
                            )
Bogdan Timofte authored 2 weeks ago
1006
                    }
1007
                }
Bogdan Timofte authored 2 weeks ago
1008
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1009
            }
Bogdan Timofte authored 2 weeks ago
1010

            
1011
            Color.clear
1012
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1013
        }
1014
    }
1015

            
Bogdan Timofte authored a week ago
1016
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1017
        height: CGFloat,
1018
        context: ChartContext,
Bogdan Timofte authored a week ago
1019
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1020
        measurementUnit: String,
1021
        tint: Color
1022
    ) -> some View {
1023
        GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1024
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1025
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1026
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1027

            
Bogdan Timofte authored 2 weeks ago
1028
            ZStack(alignment: .top) {
1029
                ForEach(0..<yLabels, id: \.self) { row in
1030
                    let labelIndex = yLabels - row
1031

            
1032
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1033
                        .font((isLargeDisplay ? Font.callout : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
1034
                        .monospacedDigit()
1035
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1036
                        .minimumScaleFactor(0.8)
1037
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1038
                        .position(
1039
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1040
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1041
                                for: labelIndex,
1042
                                context: context,
Bogdan Timofte authored 6 days ago
1043
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1044
                            )
1045
                        )
Bogdan Timofte authored 2 weeks ago
1046
                }
Bogdan Timofte authored 2 weeks ago
1047

            
Bogdan Timofte authored 2 weeks ago
1048
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1049
                    .font((isLargeDisplay ? Font.footnote : .caption2).weight(.bold))
Bogdan Timofte authored 2 weeks ago
1050
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1051
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1052
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1053
                    .background(
1054
                        Capsule(style: .continuous)
1055
                            .fill(tint.opacity(0.14))
1056
                    )
Bogdan Timofte authored 6 days ago
1057
                    .padding(.top, 8)
1058

            
1059
                VStack {
1060
                    Spacer(minLength: 0)
Bogdan Timofte authored a week ago
1061

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

            
Bogdan Timofte authored 2 weeks ago
1097
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1098
        GeometryReader { geometry in
1099
            Path { path in
Bogdan Timofte authored 2 weeks ago
1100
                for labelIndex in 1...self.yLabels {
1101
                    let y = yGuidePosition(
1102
                        for: labelIndex,
1103
                        context: context,
1104
                        height: geometry.size.height
1105
                    )
1106
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1107
                }
Bogdan Timofte authored 2 weeks ago
1108
            }
1109
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1110
        }
1111
    }
1112

            
Bogdan Timofte authored 2 weeks ago
1113
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1114
        GeometryReader { geometry in
1115
            Path { path in
1116

            
Bogdan Timofte authored 2 weeks ago
1117
                for labelIndex in 2..<self.xLabels {
1118
                    let x = xGuidePosition(
1119
                        for: labelIndex,
1120
                        context: context,
1121
                        width: geometry.size.width
1122
                    )
1123
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1124
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1125
                }
Bogdan Timofte authored 2 weeks ago
1126
            }
1127
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1128
        }
1129
    }
Bogdan Timofte authored a week ago
1130

            
1131
    fileprivate func discontinuityMarkers(
1132
        points: [Measurements.Measurement.Point],
1133
        context: ChartContext
1134
    ) -> some View {
1135
        GeometryReader { geometry in
1136
            Path { path in
1137
                for point in points where point.isDiscontinuity {
1138
                    let markerX = context.placeInRect(
1139
                        point: CGPoint(
1140
                            x: point.timestamp.timeIntervalSince1970,
1141
                            y: context.origin.y
1142
                        )
1143
                    ).x * geometry.size.width
1144
                    path.move(to: CGPoint(x: markerX, y: 0))
1145
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1146
                }
1147
            }
1148
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1149
        }
1150
    }
Bogdan Timofte authored 2 weeks ago
1151

            
1152
}
1153

            
1154
struct Chart : View {
1155

            
Bogdan Timofte authored 2 weeks ago
1156
    let points: [Measurements.Measurement.Point]
1157
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1158
    var areaChart: Bool = false
1159
    var strokeColor: Color = .black
1160

            
1161
    var body : some View {
1162
        GeometryReader { geometry in
1163
            if self.areaChart {
1164
                self.path( geometry: geometry )
1165
                    .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)))
1166
            } else {
1167
                self.path( geometry: geometry )
1168
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1169
            }
1170
        }
1171
    }
1172

            
1173
    fileprivate func path(geometry: GeometryProxy) -> Path {
1174
        return Path { path in
Bogdan Timofte authored a week ago
1175
            var firstSample: Measurements.Measurement.Point?
1176
            var lastSample: Measurements.Measurement.Point?
1177
            var needsMove = true
1178

            
1179
            for point in points {
1180
                if point.isDiscontinuity {
1181
                    needsMove = true
1182
                    continue
1183
                }
1184

            
1185
                let item = context.placeInRect(point: point.point())
1186
                let renderedPoint = CGPoint(
1187
                    x: item.x * geometry.size.width,
1188
                    y: item.y * geometry.size.height
1189
                )
1190

            
1191
                if firstSample == nil {
1192
                    firstSample = point
1193
                }
1194
                lastSample = point
1195

            
1196
                if needsMove {
1197
                    path.move(to: renderedPoint)
1198
                    needsMove = false
1199
                } else {
1200
                    path.addLine(to: renderedPoint)
1201
                }
Bogdan Timofte authored 2 weeks ago
1202
            }
Bogdan Timofte authored a week ago
1203

            
1204
            if self.areaChart, let firstSample, let lastSample {
1205
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1206
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1207
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1208
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1209
                // MARK: Nu e nevoie. Fill inchide automat calea
1210
                // path.closeSubpath()
1211
            }
1212
        }
1213
    }
1214

            
1215
}