USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
1214 lines | 44.162kb
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 {
Bogdan Timofte authored 6 days ago
137
        #if os(iOS)
138
        if UIDevice.current.userInterfaceIdiom == .phone {
139
            return false
140
        }
141
        #endif
142

            
Bogdan Timofte authored 6 days ago
143
        if availableSize.width > 0 {
144
            return availableSize.width >= 900 || availableSize.height >= 700
145
        }
146
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
147
    }
148

            
149
    private var chartBaseFont: Font {
150
        isLargeDisplay ? .callout : .footnote
151
    }
152

            
Bogdan Timofte authored 2 weeks ago
153
    var body: some View {
Bogdan Timofte authored a week ago
154
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
155
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
156
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
157
        let primarySeries = displayedPrimarySeries(
158
            powerSeries: powerSeries,
159
            voltageSeries: voltageSeries,
160
            currentSeries: currentSeries
161
        )
162

            
Bogdan Timofte authored 2 weeks ago
163
        Group {
Bogdan Timofte authored 2 weeks ago
164
            if let primarySeries {
165
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
166
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
167

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

            
171
                        VStack(spacing: 6) {
172
                            HStack(spacing: chartSectionSpacing) {
173
                                primaryAxisView(
174
                                    height: plotHeight,
175
                                    powerSeries: powerSeries,
176
                                    voltageSeries: voltageSeries,
177
                                    currentSeries: currentSeries
178
                                )
179
                                .frame(width: axisColumnWidth, height: plotHeight)
180

            
181
                                ZStack {
182
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
183
                                        .fill(Color.primary.opacity(0.05))
184

            
185
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
186
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
187

            
188
                                    horizontalGuides(context: primarySeries.context)
189
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
190
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
191
                                    renderedChart(
192
                                        powerSeries: powerSeries,
193
                                        voltageSeries: voltageSeries,
194
                                        currentSeries: currentSeries
195
                                    )
Bogdan Timofte authored 2 weeks ago
196
                                }
Bogdan Timofte authored 2 weeks ago
197
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
198
                                .frame(maxWidth: .infinity)
199
                                .frame(height: plotHeight)
200

            
201
                                secondaryAxisView(
202
                                    height: plotHeight,
203
                                    powerSeries: powerSeries,
204
                                    voltageSeries: voltageSeries,
205
                                    currentSeries: currentSeries
206
                                )
207
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
208
                            }
Bogdan Timofte authored 6 days ago
209
                            .overlay(alignment: .bottom) {
210
                                scaleControlsPill(
211
                                    voltageSeries: voltageSeries,
212
                                    currentSeries: currentSeries
213
                                )
214
                                .padding(.bottom, compactLayout ? 6 : 10)
215
                            }
Bogdan Timofte authored 2 weeks ago
216

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

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

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

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

            
Bogdan Timofte authored 6 days ago
278
    private var shouldFloatScaleControlsOverChart: Bool {
279
        #if os(iOS)
280
        if availableSize.width > 0, availableSize.height > 0 {
281
            return availableSize.width > availableSize.height
282
        }
283
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
284
        #else
285
        return false
286
        #endif
287
    }
288

            
289
    private func scaleControlsPill(
290
        voltageSeries: SeriesData,
291
        currentSeries: SeriesData
292
    ) -> some View {
293
        let condensedLayout = compactLayout || verticalSizeClass == .compact
294

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

            
Bogdan Timofte authored a week ago
316
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
317
        HStack(spacing: condensedLayout ? 6 : 8) {
318
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
319
                displayVoltage.toggle()
320
                if displayVoltage {
321
                    displayPower = false
322
                }
323
            }
324

            
325
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
326
                displayCurrent.toggle()
327
                if displayCurrent {
328
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
329
                }
Bogdan Timofte authored a week ago
330
            }
331

            
332
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
333
                displayPower.toggle()
334
                if displayPower {
335
                    displayCurrent = false
336
                    displayVoltage = false
337
                }
338
            }
339
        }
340
    }
341

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

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

            
379
            symbolControlChip(
380
                systemImage: "0.circle",
381
                enabled: true,
382
                active: pinnedOriginIsZero,
383
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
384
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
385
                label: "Origin 0",
386
                accessibilityLabel: "Set origin to zero"
387
            ) {
388
                setVisibleOriginsToZero()
389
            }
Bogdan Timofte authored 6 days ago
390

            
Bogdan Timofte authored a week ago
391
        }
392
    }
393

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

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

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

            
Bogdan Timofte authored 6 days ago
485
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
486
        if isLargeDisplay {
487
            return .body.weight(.semibold)
488
        }
489
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
490
    }
491

            
492
    private func controlChipFont(condensedLayout: Bool) -> Font {
493
        if isLargeDisplay {
494
            return .callout.weight(.semibold)
495
        }
496
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
497
    }
498

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

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

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

            
579
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
580
        powerSeries: SeriesData,
581
        voltageSeries: SeriesData,
582
        currentSeries: SeriesData
583
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
584
        if displayPower {
Bogdan Timofte authored a week ago
585
            return powerSeries
Bogdan Timofte authored 2 weeks ago
586
        }
587
        if displayVoltage {
Bogdan Timofte authored a week ago
588
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
589
        }
590
        if displayCurrent {
Bogdan Timofte authored a week ago
591
            return currentSeries
Bogdan Timofte authored 2 weeks ago
592
        }
593
        return nil
594
    }
595

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

            
608
        let autoBounds = automaticYBounds(
609
            for: samplePoints,
610
            minimumYSpan: minimumYSpan
611
        )
612
        let xBounds = xBounds(for: samplePoints)
613
        let lowerBound = resolvedLowerBound(
614
            for: kind,
615
            autoLowerBound: autoBounds.lowerBound
616
        )
617
        let upperBound = resolvedUpperBound(
618
            for: kind,
619
            lowerBound: lowerBound,
620
            autoUpperBound: autoBounds.upperBound,
621
            maximumSampleValue: samplePoints.map(\.value).max(),
622
            minimumYSpan: minimumYSpan
623
        )
624

            
625
        context.setBounds(
626
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
627
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
628
            yMin: CGFloat(lowerBound),
629
            yMax: CGFloat(upperBound)
630
        )
631

            
632
        return SeriesData(
633
            kind: kind,
634
            points: points,
635
            samplePoints: samplePoints,
636
            context: context,
637
            autoLowerBound: autoBounds.lowerBound,
638
            autoUpperBound: autoBounds.upperBound,
639
            maximumSampleValue: samplePoints.map(\.value).max()
640
        )
641
    }
642

            
643
    private var supportsSharedOrigin: Bool {
644
        displayVoltage && displayCurrent && !displayPower
645
    }
646

            
Bogdan Timofte authored 6 days ago
647
    private var minimumSharedScaleSpan: Double {
648
        max(minimumVoltageSpan, minimumCurrentSpan)
649
    }
650

            
Bogdan Timofte authored a week ago
651
    private var pinnedOriginIsZero: Bool {
652
        if useSharedOrigin && supportsSharedOrigin {
653
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
654
        }
Bogdan Timofte authored a week ago
655

            
656
        if displayPower {
657
            return pinOrigin && powerAxisOrigin == 0
658
        }
659

            
660
        let visibleOrigins = [
661
            displayVoltage ? voltageAxisOrigin : nil,
662
            displayCurrent ? currentAxisOrigin : nil
663
        ]
664
        .compactMap { $0 }
665

            
666
        guard !visibleOrigins.isEmpty else { return false }
667
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
668
    }
669

            
670
    private func toggleSharedOrigin(
671
        voltageSeries: SeriesData,
672
        currentSeries: SeriesData
673
    ) {
674
        guard supportsSharedOrigin else { return }
675

            
676
        if useSharedOrigin {
677
            useSharedOrigin = false
678
            return
679
        }
680

            
681
        captureCurrentOrigins(
682
            voltageSeries: voltageSeries,
683
            currentSeries: currentSeries
684
        )
685
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
686
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
687
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
688
        useSharedOrigin = true
689
        pinOrigin = true
690
    }
691

            
692
    private func togglePinnedOrigin(
693
        voltageSeries: SeriesData,
694
        currentSeries: SeriesData
695
    ) {
696
        if pinOrigin {
697
            pinOrigin = false
698
            return
699
        }
700

            
701
        captureCurrentOrigins(
702
            voltageSeries: voltageSeries,
703
            currentSeries: currentSeries
704
        )
705
        pinOrigin = true
706
    }
707

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

            
728
        pinOrigin = true
729
    }
730

            
731
    private func captureCurrentOrigins(
732
        voltageSeries: SeriesData,
733
        currentSeries: SeriesData
734
    ) {
735
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
736
        voltageAxisOrigin = voltageSeries.autoLowerBound
737
        currentAxisOrigin = currentSeries.autoLowerBound
738
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
739
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
740
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
741
    }
742

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

            
760
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
761
        measurement.points.filter { point in
762
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
763
        }
764
    }
765

            
766
    private func xBounds(
767
        for samplePoints: [Measurements.Measurement.Point]
768
    ) -> ClosedRange<Date> {
769
        if let timeRange {
770
            return timeRange
771
        }
772

            
773
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
774
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
775

            
776
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
777
            return lowerBound...upperBound
778
        }
779

            
780
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
781
    }
782

            
783
    private func automaticYBounds(
784
        for samplePoints: [Measurements.Measurement.Point],
785
        minimumYSpan: Double
786
    ) -> (lowerBound: Double, upperBound: Double) {
787
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
788

            
789
        guard
790
            let minimumSampleValue = samplePoints.map(\.value).min(),
791
            let maximumSampleValue = samplePoints.map(\.value).max()
792
        else {
793
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
794
        }
Bogdan Timofte authored a week ago
795

            
796
        var lowerBound = minimumSampleValue
797
        var upperBound = maximumSampleValue
798
        let currentSpan = upperBound - lowerBound
799

            
800
        if currentSpan < minimumYSpan {
801
            let expansion = (minimumYSpan - currentSpan) / 2
802
            lowerBound -= expansion
803
            upperBound += expansion
804
        }
805

            
806
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
807
            let shift = -negativeAllowance - lowerBound
808
            lowerBound += shift
809
            upperBound += shift
810
        }
811

            
812
        let snappedLowerBound = snappedOriginValue(lowerBound)
813
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
814
        return (snappedLowerBound, resolvedUpperBound)
815
    }
816

            
817
    private func resolvedLowerBound(
818
        for kind: SeriesKind,
819
        autoLowerBound: Double
820
    ) -> Double {
821
        guard pinOrigin else { return autoLowerBound }
822

            
823
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
824
            return sharedAxisOrigin
825
        }
826

            
827
        switch kind {
828
        case .power:
829
            return powerAxisOrigin
830
        case .voltage:
831
            return voltageAxisOrigin
832
        case .current:
833
            return currentAxisOrigin
834
        }
835
    }
836

            
837
    private func resolvedUpperBound(
838
        for kind: SeriesKind,
839
        lowerBound: Double,
840
        autoUpperBound: Double,
841
        maximumSampleValue: Double?,
842
        minimumYSpan: Double
843
    ) -> Double {
844
        guard pinOrigin else {
845
            return autoUpperBound
846
        }
847

            
Bogdan Timofte authored 6 days ago
848
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
849
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
850
        }
851

            
Bogdan Timofte authored a week ago
852
        return max(
853
            maximumSampleValue ?? lowerBound,
854
            lowerBound + minimumYSpan,
855
            autoUpperBound
856
        )
857
    }
858

            
Bogdan Timofte authored 6 days ago
859
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
860
        let baseline = displayedLowerBoundForSeries(kind)
861
        let proposedOrigin = snappedOriginValue(baseline + delta)
862

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

            
879
        pinOrigin = true
880
    }
881

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

            
901
        pinOrigin = true
902
    }
903

            
904
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
905
        guard totalHeight > 1 else { return }
906

            
907
        let normalized = max(0, min(1, locationY / totalHeight))
908
        if normalized < (1.0 / 3.0) {
909
            applyOriginDelta(-1, kind: kind)
910
        } else if normalized < (2.0 / 3.0) {
911
            clearOriginOffset(for: kind)
912
        } else {
913
            applyOriginDelta(1, kind: kind)
914
        }
915
    }
916

            
Bogdan Timofte authored a week ago
917
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
918
        switch kind {
919
        case .power:
920
            return snappedOriginValue(filteredSamplePoints(measurements.power).map(\.value).min() ?? 0)
921
        case .voltage:
922
            return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
923
        case .current:
924
            return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
925
        }
926
    }
927

            
928
    private func maximumVisibleSharedOrigin() -> Double {
929
        min(
930
            maximumVisibleOrigin(for: .voltage),
931
            maximumVisibleOrigin(for: .current)
932
        )
933
    }
934

            
Bogdan Timofte authored 6 days ago
935
    private func ensureSharedScaleSpan() {
936
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
937
    }
938

            
Bogdan Timofte authored a week ago
939
    private func snappedOriginValue(_ value: Double) -> Double {
940
        if value >= 0 {
941
            return value.rounded(.down)
942
        }
943

            
944
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
945
    }
Bogdan Timofte authored 2 weeks ago
946

            
947
    private func yGuidePosition(
948
        for labelIndex: Int,
949
        context: ChartContext,
950
        height: CGFloat
951
    ) -> CGFloat {
952
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
953
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
954
        return context.placeInRect(point: anchorPoint).y * height
955
    }
956

            
957
    private func xGuidePosition(
958
        for labelIndex: Int,
959
        context: ChartContext,
960
        width: CGFloat
961
    ) -> CGFloat {
962
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
963
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
964
        return context.placeInRect(point: anchorPoint).x * width
965
    }
Bogdan Timofte authored 2 weeks ago
966

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

            
981
        return HStack(spacing: chartSectionSpacing) {
982
            Color.clear
983
                .frame(width: axisColumnWidth)
984

            
985
            GeometryReader { geometry in
986
                let labelWidth = max(
987
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
988
                    1
989
                )
990

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

            
1005
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1006
                        let labelIndex = item.offset + 1
1007
                        let centerX = xGuidePosition(
1008
                            for: labelIndex,
1009
                            context: context,
1010
                            width: geometry.size.width
1011
                        )
1012

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

            
1028
            Color.clear
1029
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1030
        }
1031
    }
1032

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

            
Bogdan Timofte authored 2 weeks ago
1045
            ZStack(alignment: .top) {
1046
                ForEach(0..<yLabels, id: \.self) { row in
1047
                    let labelIndex = yLabels - row
1048

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

            
Bogdan Timofte authored 2 weeks ago
1065
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1066
                    .font((isLargeDisplay ? Font.footnote : .caption2).weight(.bold))
Bogdan Timofte authored 2 weeks ago
1067
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1068
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1069
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1070
                    .background(
1071
                        Capsule(style: .continuous)
1072
                            .fill(tint.opacity(0.14))
1073
                    )
Bogdan Timofte authored 6 days ago
1074
                    .padding(.top, 8)
1075

            
Bogdan Timofte authored 2 weeks ago
1076
            }
1077
        }
Bogdan Timofte authored 2 weeks ago
1078
        .frame(height: height)
1079
        .background(
1080
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1081
                .fill(tint.opacity(0.12))
1082
        )
1083
        .overlay(
1084
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1085
                .stroke(tint.opacity(0.20), lineWidth: 1)
1086
        )
Bogdan Timofte authored a week ago
1087
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1088
        .gesture(
Bogdan Timofte authored 6 days ago
1089
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1090
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1091
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1092
                }
1093
        )
Bogdan Timofte authored 2 weeks ago
1094
    }
1095

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

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

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

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

            
1151
}
1152

            
1153
struct Chart : View {
1154

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

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

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

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

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

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

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

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

            
1214
}