USB-Meter / USB Meter / Views / Meter / Components / MeasurementChartView.swift
Newer Older
1262 lines | 46.218kb
Bogdan Timofte authored 2 weeks ago
1
//
2
//  MeasurementChartView.swift
3
//  USB Meter
4
//
5
//  Created by Bogdan Timofte on 06/05/2020.
6
//  Copyright © 2020 Bogdan Timofte. All rights reserved.
7
//
8

            
9
import SwiftUI
10

            
11
struct MeasurementChartView: View {
Bogdan Timofte authored a week ago
12
    private enum SeriesKind {
13
        case power
14
        case voltage
15
        case current
16

            
17
        var unit: String {
18
            switch self {
19
            case .power: return "W"
20
            case .voltage: return "V"
21
            case .current: return "A"
22
            }
23
        }
24

            
25
        var tint: Color {
26
            switch self {
27
            case .power: return .red
28
            case .voltage: return .green
29
            case .current: return .blue
30
            }
31
        }
32
    }
33

            
34
    private struct SeriesData {
35
        let kind: SeriesKind
36
        let points: [Measurements.Measurement.Point]
37
        let samplePoints: [Measurements.Measurement.Point]
38
        let context: ChartContext
39
        let autoLowerBound: Double
40
        let autoUpperBound: Double
41
        let maximumSampleValue: Double?
42
    }
43

            
Bogdan Timofte authored 2 weeks ago
44
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 weeks ago
45
    private let minimumVoltageSpan = 0.5
46
    private let minimumCurrentSpan = 0.5
47
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored a week ago
48
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
49

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

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

            
58
    @State var displayVoltage: Bool = false
59
    @State var displayCurrent: Bool = false
60
    @State var displayPower: Bool = true
Bogdan Timofte authored a week ago
61
    @State private var showResetConfirmation: Bool = false
62
    @State private var chartNow: Date = Date()
63
    @State private var pinOrigin: Bool = false
64
    @State private var useSharedOrigin: Bool = false
65
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 6 days ago
66
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored a week ago
67
    @State private var powerAxisOrigin: Double = 0
68
    @State private var voltageAxisOrigin: Double = 0
69
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 2 weeks ago
70
    let xLabels: Int = 4
71
    let yLabels: Int = 4
72

            
Bogdan Timofte authored a week ago
73
    init(
74
        compactLayout: Bool = false,
75
        availableSize: CGSize = .zero,
76
        timeRange: ClosedRange<Date>? = nil
77
    ) {
78
        self.compactLayout = compactLayout
79
        self.availableSize = availableSize
80
        self.timeRange = timeRange
81
    }
82

            
83
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 6 days ago
84
        if compactLayout {
85
            return 38
86
        }
87
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored a week ago
88
    }
89

            
90
    private var chartSectionSpacing: CGFloat {
91
        compactLayout ? 6 : 8
92
    }
93

            
94
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 6 days ago
95
        if compactLayout {
96
            return 24
97
        }
98
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored a week ago
99
    }
100

            
Bogdan Timofte authored 6 days ago
101
    private var isPortraitLayout: Bool {
102
        guard availableSize != .zero else { return verticalSizeClass != .compact }
103
        return availableSize.height >= availableSize.width
104
    }
105

            
Bogdan Timofte authored 6 days ago
106
    private var isIPhone: Bool {
107
        #if os(iOS)
108
        return UIDevice.current.userInterfaceIdiom == .phone
109
        #else
110
        return false
111
        #endif
112
    }
113

            
114
    private enum OriginControlsPlacement {
115
        case aboveXAxisLegend
116
        case overXAxisLegend
117
        case belowXAxisLegend
118
    }
119

            
120
    private var originControlsPlacement: OriginControlsPlacement {
121
        if isIPhone {
122
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
123
        }
124
        return .belowXAxisLegend
125
    }
126

            
Bogdan Timofte authored a week ago
127
    private var plotSectionHeight: CGFloat {
128
        if availableSize == .zero {
Bogdan Timofte authored 6 days ago
129
            return compactLayout ? 300 : 380
130
        }
131

            
132
        if isPortraitLayout {
133
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
134
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
135
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored a week ago
136
        }
137

            
138
        if compactLayout {
139
            return min(max(availableSize.height * 0.36, 240), 300)
140
        }
141

            
142
        return min(max(availableSize.height * 0.5, 300), 440)
143
    }
144

            
145
    private var stackedToolbarLayout: Bool {
146
        if availableSize.width > 0 {
147
            return availableSize.width < 640
148
        }
149

            
150
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
151
    }
152

            
153
    private var showsLabeledOriginControls: Bool {
154
        !compactLayout && !stackedToolbarLayout
155
    }
156

            
Bogdan Timofte authored 6 days ago
157
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 6 days ago
158
        #if os(iOS)
159
        if UIDevice.current.userInterfaceIdiom == .phone {
160
            return false
161
        }
162
        #endif
163

            
Bogdan Timofte authored 6 days ago
164
        if availableSize.width > 0 {
165
            return availableSize.width >= 900 || availableSize.height >= 700
166
        }
167
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
168
    }
169

            
170
    private var chartBaseFont: Font {
171
        isLargeDisplay ? .callout : .footnote
172
    }
173

            
Bogdan Timofte authored 2 weeks ago
174
    var body: some View {
Bogdan Timofte authored a week ago
175
        let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
176
        let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
177
        let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
Bogdan Timofte authored 2 weeks ago
178
        let primarySeries = displayedPrimarySeries(
179
            powerSeries: powerSeries,
180
            voltageSeries: voltageSeries,
181
            currentSeries: currentSeries
182
        )
183

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

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

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

            
202
                                ZStack {
203
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
204
                                        .fill(Color.primary.opacity(0.05))
205

            
206
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
207
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
208

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

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

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

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

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

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

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

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

            
337
    private func scaleControlsPill(
338
        voltageSeries: SeriesData,
339
        currentSeries: SeriesData
340
    ) -> some View {
341
        let condensedLayout = compactLayout || verticalSizeClass == .compact
342

            
343
        return originControlsRow(
344
            voltageSeries: voltageSeries,
345
            currentSeries: currentSeries,
346
            condensedLayout: condensedLayout,
347
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
348
        )
349
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
350
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
351
        .background(
352
            Capsule(style: .continuous)
353
                .fill(shouldFloatScaleControlsOverChart ? Color.clear : Color.primary.opacity(0.08))
354
        )
355
        .overlay(
356
            Capsule(style: .continuous)
357
                .stroke(
358
                    shouldFloatScaleControlsOverChart ? Color.clear : Color.secondary.opacity(0.18),
359
                    lineWidth: 1
360
                )
361
        )
362
    }
363

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

            
373
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
374
                displayCurrent.toggle()
375
                if displayCurrent {
376
                    displayPower = false
Bogdan Timofte authored 2 weeks ago
377
                }
Bogdan Timofte authored a week ago
378
            }
379

            
380
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
381
                displayPower.toggle()
382
                if displayPower {
383
                    displayCurrent = false
384
                    displayVoltage = false
385
                }
386
            }
387
        }
388
    }
389

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

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

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

            
Bogdan Timofte authored a week ago
439
        }
440
    }
441

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

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

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

            
Bogdan Timofte authored 6 days ago
533
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
534
        if isLargeDisplay {
535
            return .body.weight(.semibold)
536
        }
537
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
538
    }
539

            
540
    private func controlChipFont(condensedLayout: Bool) -> Font {
541
        if isLargeDisplay {
542
            return .callout.weight(.semibold)
543
        }
544
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
545
    }
546

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

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

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

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

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

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

            
673
        context.setBounds(
674
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
675
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
676
            yMin: CGFloat(lowerBound),
677
            yMax: CGFloat(upperBound)
678
        )
679

            
680
        return SeriesData(
681
            kind: kind,
682
            points: points,
683
            samplePoints: samplePoints,
684
            context: context,
685
            autoLowerBound: autoBounds.lowerBound,
686
            autoUpperBound: autoBounds.upperBound,
687
            maximumSampleValue: samplePoints.map(\.value).max()
688
        )
689
    }
690

            
691
    private var supportsSharedOrigin: Bool {
692
        displayVoltage && displayCurrent && !displayPower
693
    }
694

            
Bogdan Timofte authored 6 days ago
695
    private var minimumSharedScaleSpan: Double {
696
        max(minimumVoltageSpan, minimumCurrentSpan)
697
    }
698

            
Bogdan Timofte authored a week ago
699
    private var pinnedOriginIsZero: Bool {
700
        if useSharedOrigin && supportsSharedOrigin {
701
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
702
        }
Bogdan Timofte authored a week ago
703

            
704
        if displayPower {
705
            return pinOrigin && powerAxisOrigin == 0
706
        }
707

            
708
        let visibleOrigins = [
709
            displayVoltage ? voltageAxisOrigin : nil,
710
            displayCurrent ? currentAxisOrigin : nil
711
        ]
712
        .compactMap { $0 }
713

            
714
        guard !visibleOrigins.isEmpty else { return false }
715
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
716
    }
717

            
718
    private func toggleSharedOrigin(
719
        voltageSeries: SeriesData,
720
        currentSeries: SeriesData
721
    ) {
722
        guard supportsSharedOrigin else { return }
723

            
724
        if useSharedOrigin {
725
            useSharedOrigin = false
726
            return
727
        }
728

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

            
740
    private func togglePinnedOrigin(
741
        voltageSeries: SeriesData,
742
        currentSeries: SeriesData
743
    ) {
744
        if pinOrigin {
745
            pinOrigin = false
746
            return
747
        }
748

            
749
        captureCurrentOrigins(
750
            voltageSeries: voltageSeries,
751
            currentSeries: currentSeries
752
        )
753
        pinOrigin = true
754
    }
755

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

            
776
        pinOrigin = true
777
    }
778

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

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

            
808
    private func filteredSamplePoints(_ measurement: Measurements.Measurement) -> [Measurements.Measurement.Point] {
809
        measurement.points.filter { point in
810
            point.isSample && (timeRange?.contains(point.timestamp) ?? true)
811
        }
812
    }
813

            
814
    private func xBounds(
815
        for samplePoints: [Measurements.Measurement.Point]
816
    ) -> ClosedRange<Date> {
817
        if let timeRange {
818
            return timeRange
819
        }
820

            
821
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
822
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
823

            
824
        if upperBound.timeIntervalSince(lowerBound) >= minimumTimeSpan {
825
            return lowerBound...upperBound
826
        }
827

            
828
        return upperBound.addingTimeInterval(-minimumTimeSpan)...upperBound
829
    }
830

            
831
    private func automaticYBounds(
832
        for samplePoints: [Measurements.Measurement.Point],
833
        minimumYSpan: Double
834
    ) -> (lowerBound: Double, upperBound: Double) {
835
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
836

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

            
844
        var lowerBound = minimumSampleValue
845
        var upperBound = maximumSampleValue
846
        let currentSpan = upperBound - lowerBound
847

            
848
        if currentSpan < minimumYSpan {
849
            let expansion = (minimumYSpan - currentSpan) / 2
850
            lowerBound -= expansion
851
            upperBound += expansion
852
        }
853

            
854
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
855
            let shift = -negativeAllowance - lowerBound
856
            lowerBound += shift
857
            upperBound += shift
858
        }
859

            
860
        let snappedLowerBound = snappedOriginValue(lowerBound)
861
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
862
        return (snappedLowerBound, resolvedUpperBound)
863
    }
864

            
865
    private func resolvedLowerBound(
866
        for kind: SeriesKind,
867
        autoLowerBound: Double
868
    ) -> Double {
869
        guard pinOrigin else { return autoLowerBound }
870

            
871
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
872
            return sharedAxisOrigin
873
        }
874

            
875
        switch kind {
876
        case .power:
877
            return powerAxisOrigin
878
        case .voltage:
879
            return voltageAxisOrigin
880
        case .current:
881
            return currentAxisOrigin
882
        }
883
    }
884

            
885
    private func resolvedUpperBound(
886
        for kind: SeriesKind,
887
        lowerBound: Double,
888
        autoUpperBound: Double,
889
        maximumSampleValue: Double?,
890
        minimumYSpan: Double
891
    ) -> Double {
892
        guard pinOrigin else {
893
            return autoUpperBound
894
        }
895

            
Bogdan Timofte authored 6 days ago
896
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
897
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
898
        }
899

            
Bogdan Timofte authored a week ago
900
        return max(
901
            maximumSampleValue ?? lowerBound,
902
            lowerBound + minimumYSpan,
903
            autoUpperBound
904
        )
905
    }
906

            
Bogdan Timofte authored 6 days ago
907
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
908
        let baseline = displayedLowerBoundForSeries(kind)
909
        let proposedOrigin = snappedOriginValue(baseline + delta)
910

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

            
927
        pinOrigin = true
928
    }
929

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

            
949
        pinOrigin = true
950
    }
951

            
952
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
953
        guard totalHeight > 1 else { return }
954

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

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

            
976
    private func maximumVisibleSharedOrigin() -> Double {
977
        min(
978
            maximumVisibleOrigin(for: .voltage),
979
            maximumVisibleOrigin(for: .current)
980
        )
981
    }
982

            
Bogdan Timofte authored 6 days ago
983
    private func ensureSharedScaleSpan() {
984
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
985
    }
986

            
Bogdan Timofte authored a week ago
987
    private func snappedOriginValue(_ value: Double) -> Double {
988
        if value >= 0 {
989
            return value.rounded(.down)
990
        }
991

            
992
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
993
    }
Bogdan Timofte authored 2 weeks ago
994

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

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

            
1015
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
1016
    fileprivate func xAxisLabelsView(
1017
        context: ChartContext
1018
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
1019
        var timeFormat: String?
1020
        switch context.size.width {
1021
        case 0..<3600: timeFormat = "HH:mm:ss"
1022
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
1023
        default: timeFormat = "E HH:mm"
1024
        }
1025
        let labels = (1...xLabels).map {
1026
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
1027
        }
Bogdan Timofte authored 2 weeks ago
1028

            
1029
        return HStack(spacing: chartSectionSpacing) {
1030
            Color.clear
1031
                .frame(width: axisColumnWidth)
1032

            
1033
            GeometryReader { geometry in
1034
                let labelWidth = max(
1035
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1036
                    1
1037
                )
1038

            
1039
                ZStack(alignment: .topLeading) {
1040
                    Path { path in
1041
                        for labelIndex in 1...self.xLabels {
1042
                            let x = xGuidePosition(
1043
                                for: labelIndex,
1044
                                context: context,
1045
                                width: geometry.size.width
1046
                            )
1047
                            path.move(to: CGPoint(x: x, y: 0))
1048
                            path.addLine(to: CGPoint(x: x, y: 6))
1049
                        }
1050
                    }
1051
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1052

            
1053
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1054
                        let labelIndex = item.offset + 1
1055
                        let centerX = xGuidePosition(
1056
                            for: labelIndex,
1057
                            context: context,
1058
                            width: geometry.size.width
1059
                        )
1060

            
1061
                        Text(item.element)
Bogdan Timofte authored 6 days ago
1062
                            .font((isLargeDisplay ? Font.callout : .caption).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
1063
                            .monospacedDigit()
1064
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1065
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1066
                            .frame(width: labelWidth)
1067
                            .position(
1068
                                x: centerX,
1069
                                y: geometry.size.height * 0.7
1070
                            )
Bogdan Timofte authored 2 weeks ago
1071
                    }
1072
                }
Bogdan Timofte authored 2 weeks ago
1073
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1074
            }
Bogdan Timofte authored 2 weeks ago
1075

            
1076
            Color.clear
1077
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1078
        }
1079
    }
1080

            
Bogdan Timofte authored a week ago
1081
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1082
        height: CGFloat,
1083
        context: ChartContext,
Bogdan Timofte authored a week ago
1084
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1085
        measurementUnit: String,
1086
        tint: Color
1087
    ) -> some View {
1088
        GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1089
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1090
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1091
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1092

            
Bogdan Timofte authored 2 weeks ago
1093
            ZStack(alignment: .top) {
1094
                ForEach(0..<yLabels, id: \.self) { row in
1095
                    let labelIndex = yLabels - row
1096

            
1097
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 6 days ago
1098
                        .font((isLargeDisplay ? Font.callout : .footnote).weight(.semibold))
Bogdan Timofte authored 2 weeks ago
1099
                        .monospacedDigit()
1100
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1101
                        .minimumScaleFactor(0.8)
1102
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1103
                        .position(
1104
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1105
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1106
                                for: labelIndex,
1107
                                context: context,
Bogdan Timofte authored 6 days ago
1108
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1109
                            )
1110
                        )
Bogdan Timofte authored 2 weeks ago
1111
                }
Bogdan Timofte authored 2 weeks ago
1112

            
Bogdan Timofte authored 2 weeks ago
1113
                Text(measurementUnit)
Bogdan Timofte authored 6 days ago
1114
                    .font((isLargeDisplay ? Font.footnote : .caption2).weight(.bold))
Bogdan Timofte authored 2 weeks ago
1115
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1116
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1117
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1118
                    .background(
1119
                        Capsule(style: .continuous)
1120
                            .fill(tint.opacity(0.14))
1121
                    )
Bogdan Timofte authored 6 days ago
1122
                    .padding(.top, 8)
1123

            
Bogdan Timofte authored 2 weeks ago
1124
            }
1125
        }
Bogdan Timofte authored 2 weeks ago
1126
        .frame(height: height)
1127
        .background(
1128
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1129
                .fill(tint.opacity(0.12))
1130
        )
1131
        .overlay(
1132
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1133
                .stroke(tint.opacity(0.20), lineWidth: 1)
1134
        )
Bogdan Timofte authored a week ago
1135
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1136
        .gesture(
Bogdan Timofte authored 6 days ago
1137
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1138
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1139
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1140
                }
1141
        )
Bogdan Timofte authored 2 weeks ago
1142
    }
1143

            
Bogdan Timofte authored 2 weeks ago
1144
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1145
        GeometryReader { geometry in
1146
            Path { path in
Bogdan Timofte authored 2 weeks ago
1147
                for labelIndex in 1...self.yLabels {
1148
                    let y = yGuidePosition(
1149
                        for: labelIndex,
1150
                        context: context,
1151
                        height: geometry.size.height
1152
                    )
1153
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1154
                }
Bogdan Timofte authored 2 weeks ago
1155
            }
1156
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1157
        }
1158
    }
1159

            
Bogdan Timofte authored 2 weeks ago
1160
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1161
        GeometryReader { geometry in
1162
            Path { path in
1163

            
Bogdan Timofte authored 2 weeks ago
1164
                for labelIndex in 2..<self.xLabels {
1165
                    let x = xGuidePosition(
1166
                        for: labelIndex,
1167
                        context: context,
1168
                        width: geometry.size.width
1169
                    )
1170
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1171
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1172
                }
Bogdan Timofte authored 2 weeks ago
1173
            }
1174
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1175
        }
1176
    }
Bogdan Timofte authored a week ago
1177

            
1178
    fileprivate func discontinuityMarkers(
1179
        points: [Measurements.Measurement.Point],
1180
        context: ChartContext
1181
    ) -> some View {
1182
        GeometryReader { geometry in
1183
            Path { path in
1184
                for point in points where point.isDiscontinuity {
1185
                    let markerX = context.placeInRect(
1186
                        point: CGPoint(
1187
                            x: point.timestamp.timeIntervalSince1970,
1188
                            y: context.origin.y
1189
                        )
1190
                    ).x * geometry.size.width
1191
                    path.move(to: CGPoint(x: markerX, y: 0))
1192
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1193
                }
1194
            }
1195
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1196
        }
1197
    }
Bogdan Timofte authored 2 weeks ago
1198

            
1199
}
1200

            
1201
struct Chart : View {
1202

            
Bogdan Timofte authored 2 weeks ago
1203
    let points: [Measurements.Measurement.Point]
1204
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
1205
    var areaChart: Bool = false
1206
    var strokeColor: Color = .black
1207

            
1208
    var body : some View {
1209
        GeometryReader { geometry in
1210
            if self.areaChart {
1211
                self.path( geometry: geometry )
1212
                    .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)))
1213
            } else {
1214
                self.path( geometry: geometry )
1215
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
1216
            }
1217
        }
1218
    }
1219

            
1220
    fileprivate func path(geometry: GeometryProxy) -> Path {
1221
        return Path { path in
Bogdan Timofte authored a week ago
1222
            var firstSample: Measurements.Measurement.Point?
1223
            var lastSample: Measurements.Measurement.Point?
1224
            var needsMove = true
1225

            
1226
            for point in points {
1227
                if point.isDiscontinuity {
1228
                    needsMove = true
1229
                    continue
1230
                }
1231

            
1232
                let item = context.placeInRect(point: point.point())
1233
                let renderedPoint = CGPoint(
1234
                    x: item.x * geometry.size.width,
1235
                    y: item.y * geometry.size.height
1236
                )
1237

            
1238
                if firstSample == nil {
1239
                    firstSample = point
1240
                }
1241
                lastSample = point
1242

            
1243
                if needsMove {
1244
                    path.move(to: renderedPoint)
1245
                    needsMove = false
1246
                } else {
1247
                    path.addLine(to: renderedPoint)
1248
                }
Bogdan Timofte authored 2 weeks ago
1249
            }
Bogdan Timofte authored a week ago
1250

            
1251
            if self.areaChart, let firstSample, let lastSample {
1252
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
1253
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
1254
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
1255
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
1256
                // MARK: Nu e nevoie. Fill inchide automat calea
1257
                // path.closeSubpath()
1258
            }
1259
        }
1260
    }
1261

            
1262
}