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

            
Bogdan Timofte authored 4 days ago
11
private enum PresentTrackingMode: CaseIterable, Hashable {
12
    case keepDuration
13
    case keepStartTimestamp
14
}
15

            
Bogdan Timofte authored 2 weeks ago
16
struct MeasurementChartView: View {
Bogdan Timofte authored a week ago
17
    private enum SeriesKind {
18
        case power
19
        case voltage
20
        case current
Bogdan Timofte authored 4 days ago
21
        case temperature
Bogdan Timofte authored a week ago
22

            
23
        var unit: String {
24
            switch self {
25
            case .power: return "W"
26
            case .voltage: return "V"
27
            case .current: return "A"
Bogdan Timofte authored 4 days ago
28
            case .temperature: return ""
Bogdan Timofte authored a week ago
29
            }
30
        }
31

            
32
        var tint: Color {
33
            switch self {
34
            case .power: return .red
35
            case .voltage: return .green
36
            case .current: return .blue
Bogdan Timofte authored 4 days ago
37
            case .temperature: return .orange
Bogdan Timofte authored a week ago
38
            }
39
        }
40
    }
41

            
42
    private struct SeriesData {
43
        let kind: SeriesKind
44
        let points: [Measurements.Measurement.Point]
45
        let samplePoints: [Measurements.Measurement.Point]
46
        let context: ChartContext
47
        let autoLowerBound: Double
48
        let autoUpperBound: Double
49
        let maximumSampleValue: Double?
50
    }
51

            
Bogdan Timofte authored 2 weeks ago
52
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 weeks ago
53
    private let minimumVoltageSpan = 0.5
54
    private let minimumCurrentSpan = 0.5
55
    private let minimumPowerSpan = 0.5
Bogdan Timofte authored 4 days ago
56
    private let minimumTemperatureSpan = 1.0
Bogdan Timofte authored a week ago
57
    private let defaultEmptyChartTimeSpan: TimeInterval = 60
58

            
59
    let compactLayout: Bool
60
    let availableSize: CGSize
Bogdan Timofte authored 2 weeks ago
61

            
62
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored a week ago
63
    @Environment(\.horizontalSizeClass) private var horizontalSizeClass
64
    @Environment(\.verticalSizeClass) private var verticalSizeClass
Bogdan Timofte authored 2 weeks ago
65
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 weeks ago
66

            
67
    @State var displayVoltage: Bool = false
68
    @State var displayCurrent: Bool = false
69
    @State var displayPower: Bool = true
Bogdan Timofte authored 4 days ago
70
    @State var displayTemperature: Bool = false
Bogdan Timofte authored a week ago
71
    @State private var showResetConfirmation: Bool = false
72
    @State private var chartNow: Date = Date()
Bogdan Timofte authored 4 days ago
73
    @State private var selectedVisibleTimeRange: ClosedRange<Date>?
74
    @State private var isPinnedToPresent: Bool = false
75
    @State private var presentTrackingMode: PresentTrackingMode = .keepDuration
Bogdan Timofte authored a week ago
76
    @State private var pinOrigin: Bool = false
77
    @State private var useSharedOrigin: Bool = false
78
    @State private var sharedAxisOrigin: Double = 0
Bogdan Timofte authored 6 days ago
79
    @State private var sharedAxisUpperBound: Double = 1
Bogdan Timofte authored a week ago
80
    @State private var powerAxisOrigin: Double = 0
81
    @State private var voltageAxisOrigin: Double = 0
82
    @State private var currentAxisOrigin: Double = 0
Bogdan Timofte authored 4 days ago
83
    @State private var temperatureAxisOrigin: Double = 0
Bogdan Timofte authored 2 weeks ago
84
    let xLabels: Int = 4
85
    let yLabels: Int = 4
86

            
Bogdan Timofte authored a week ago
87
    init(
88
        compactLayout: Bool = false,
89
        availableSize: CGSize = .zero,
90
        timeRange: ClosedRange<Date>? = nil
91
    ) {
92
        self.compactLayout = compactLayout
93
        self.availableSize = availableSize
94
        self.timeRange = timeRange
95
    }
96

            
97
    private var axisColumnWidth: CGFloat {
Bogdan Timofte authored 6 days ago
98
        if compactLayout {
99
            return 38
100
        }
101
        return isLargeDisplay ? 62 : 46
Bogdan Timofte authored a week ago
102
    }
103

            
104
    private var chartSectionSpacing: CGFloat {
105
        compactLayout ? 6 : 8
106
    }
107

            
108
    private var xAxisHeight: CGFloat {
Bogdan Timofte authored 6 days ago
109
        if compactLayout {
110
            return 24
111
        }
112
        return isLargeDisplay ? 36 : 28
Bogdan Timofte authored a week ago
113
    }
114

            
Bogdan Timofte authored 6 days ago
115
    private var isPortraitLayout: Bool {
116
        guard availableSize != .zero else { return verticalSizeClass != .compact }
117
        return availableSize.height >= availableSize.width
118
    }
119

            
Bogdan Timofte authored 5 days ago
120
    private var isIPhone: Bool {
121
        #if os(iOS)
122
        return UIDevice.current.userInterfaceIdiom == .phone
123
        #else
124
        return false
125
        #endif
126
    }
127

            
128
    private enum OriginControlsPlacement {
129
        case aboveXAxisLegend
130
        case overXAxisLegend
131
        case belowXAxisLegend
132
    }
133

            
134
    private var originControlsPlacement: OriginControlsPlacement {
135
        if isIPhone {
136
            return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend
137
        }
138
        return .belowXAxisLegend
139
    }
140

            
Bogdan Timofte authored a week ago
141
    private var plotSectionHeight: CGFloat {
142
        if availableSize == .zero {
Bogdan Timofte authored 6 days ago
143
            return compactLayout ? 300 : 380
144
        }
145

            
146
        if isPortraitLayout {
147
            // Keep the rendered plot area (plot section minus X axis) above half of the display height.
148
            let minimumPlotHeight = max(availableSize.height * 0.52, compactLayout ? 250 : 320)
149
            return minimumPlotHeight + xAxisHeight
Bogdan Timofte authored a week ago
150
        }
151

            
152
        if compactLayout {
153
            return min(max(availableSize.height * 0.36, 240), 300)
154
        }
155

            
156
        return min(max(availableSize.height * 0.5, 300), 440)
157
    }
158

            
159
    private var stackedToolbarLayout: Bool {
160
        if availableSize.width > 0 {
161
            return availableSize.width < 640
162
        }
163

            
164
        return horizontalSizeClass == .compact && verticalSizeClass != .compact
165
    }
166

            
167
    private var showsLabeledOriginControls: Bool {
168
        !compactLayout && !stackedToolbarLayout
169
    }
170

            
Bogdan Timofte authored 6 days ago
171
    private var isLargeDisplay: Bool {
Bogdan Timofte authored 6 days ago
172
        #if os(iOS)
173
        if UIDevice.current.userInterfaceIdiom == .phone {
174
            return false
175
        }
176
        #endif
177

            
Bogdan Timofte authored 6 days ago
178
        if availableSize.width > 0 {
179
            return availableSize.width >= 900 || availableSize.height >= 700
180
        }
181
        return !compactLayout && horizontalSizeClass == .regular && verticalSizeClass == .regular
182
    }
183

            
184
    private var chartBaseFont: Font {
Bogdan Timofte authored 5 days ago
185
        if isIPhone && isPortraitLayout {
186
            return .caption
187
        }
188
        return isLargeDisplay ? .callout : .footnote
Bogdan Timofte authored 6 days ago
189
    }
190

            
Bogdan Timofte authored 5 days ago
191
    private var usesCompactLandscapeOriginControls: Bool {
192
        isIPhone && !isPortraitLayout && availableSize.width > 0 && availableSize.width <= 740
193
    }
194

            
Bogdan Timofte authored 2 weeks ago
195
    var body: some View {
Bogdan Timofte authored 4 days ago
196
        let availableTimeRange = availableSelectionTimeRange()
197
        let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
198
        let powerSeries = series(
199
            for: measurements.power,
200
            kind: .power,
201
            minimumYSpan: minimumPowerSpan,
202
            visibleTimeRange: visibleTimeRange
203
        )
204
        let voltageSeries = series(
205
            for: measurements.voltage,
206
            kind: .voltage,
207
            minimumYSpan: minimumVoltageSpan,
208
            visibleTimeRange: visibleTimeRange
209
        )
210
        let currentSeries = series(
211
            for: measurements.current,
212
            kind: .current,
213
            minimumYSpan: minimumCurrentSpan,
214
            visibleTimeRange: visibleTimeRange
215
        )
216
        let temperatureSeries = series(
217
            for: measurements.temperature,
218
            kind: .temperature,
219
            minimumYSpan: minimumTemperatureSpan,
220
            visibleTimeRange: visibleTimeRange
221
        )
Bogdan Timofte authored 2 weeks ago
222
        let primarySeries = displayedPrimarySeries(
223
            powerSeries: powerSeries,
224
            voltageSeries: voltageSeries,
225
            currentSeries: currentSeries
226
        )
Bogdan Timofte authored 4 days ago
227
        let selectorSeries = primarySeries.map { overviewSeries(for: $0.kind) }
Bogdan Timofte authored 2 weeks ago
228

            
Bogdan Timofte authored 2 weeks ago
229
        Group {
Bogdan Timofte authored 2 weeks ago
230
            if let primarySeries {
231
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
232
                    chartToggleBar()
Bogdan Timofte authored 2 weeks ago
233

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

            
237
                        VStack(spacing: 6) {
238
                            HStack(spacing: chartSectionSpacing) {
239
                                primaryAxisView(
240
                                    height: plotHeight,
241
                                    powerSeries: powerSeries,
242
                                    voltageSeries: voltageSeries,
243
                                    currentSeries: currentSeries
244
                                )
245
                                .frame(width: axisColumnWidth, height: plotHeight)
246

            
247
                                ZStack {
248
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
249
                                        .fill(Color.primary.opacity(0.05))
250

            
251
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
252
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
253

            
254
                                    horizontalGuides(context: primarySeries.context)
255
                                    verticalGuides(context: primarySeries.context)
Bogdan Timofte authored a week ago
256
                                    discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
257
                                    renderedChart(
258
                                        powerSeries: powerSeries,
259
                                        voltageSeries: voltageSeries,
Bogdan Timofte authored 4 days ago
260
                                        currentSeries: currentSeries,
261
                                        temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 weeks ago
262
                                    )
Bogdan Timofte authored 2 weeks ago
263
                                }
Bogdan Timofte authored 2 weeks ago
264
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
265
                                .frame(maxWidth: .infinity)
266
                                .frame(height: plotHeight)
267

            
268
                                secondaryAxisView(
269
                                    height: plotHeight,
270
                                    powerSeries: powerSeries,
271
                                    voltageSeries: voltageSeries,
Bogdan Timofte authored 4 days ago
272
                                    currentSeries: currentSeries,
273
                                    temperatureSeries: temperatureSeries
Bogdan Timofte authored 2 weeks ago
274
                                )
275
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
276
                            }
Bogdan Timofte authored 6 days ago
277
                            .overlay(alignment: .bottom) {
Bogdan Timofte authored 5 days ago
278
                                if originControlsPlacement == .aboveXAxisLegend {
279
                                    scaleControlsPill(
280
                                        voltageSeries: voltageSeries,
281
                                        currentSeries: currentSeries
282
                                    )
283
                                    .padding(.bottom, compactLayout ? 6 : 10)
284
                                }
Bogdan Timofte authored 6 days ago
285
                            }
Bogdan Timofte authored 2 weeks ago
286

            
Bogdan Timofte authored 5 days ago
287
                            switch originControlsPlacement {
288
                            case .aboveXAxisLegend:
289
                                xAxisLabelsView(context: primarySeries.context)
290
                                    .frame(height: xAxisHeight)
291
                            case .overXAxisLegend:
292
                                xAxisLabelsView(context: primarySeries.context)
293
                                    .frame(height: xAxisHeight)
294
                                    .overlay(alignment: .center) {
295
                                        scaleControlsPill(
296
                                            voltageSeries: voltageSeries,
297
                                            currentSeries: currentSeries
298
                                        )
Bogdan Timofte authored 5 days ago
299
                                        .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10))
Bogdan Timofte authored 5 days ago
300
                                    }
301
                            case .belowXAxisLegend:
302
                                xAxisLabelsView(context: primarySeries.context)
303
                                    .frame(height: xAxisHeight)
304

            
305
                                HStack {
306
                                    Spacer(minLength: 0)
307
                                    scaleControlsPill(
308
                                        voltageSeries: voltageSeries,
309
                                        currentSeries: currentSeries
310
                                    )
311
                                    Spacer(minLength: 0)
312
                                }
313
                            }
Bogdan Timofte authored 4 days ago
314

            
315
                            if let availableTimeRange,
316
                               let selectorSeries,
317
                               shouldShowRangeSelector(
318
                                availableTimeRange: availableTimeRange,
319
                                series: selectorSeries
320
                               ) {
321
                                TimeRangeSelectorView(
322
                                    points: selectorSeries.points,
323
                                    context: selectorSeries.context,
324
                                    availableTimeRange: availableTimeRange,
325
                                    accentColor: selectorSeries.kind.tint,
326
                                    compactLayout: compactLayout,
327
                                    minimumSelectionSpan: minimumTimeSpan,
328
                                    selectedTimeRange: $selectedVisibleTimeRange,
329
                                    isPinnedToPresent: $isPinnedToPresent,
330
                                    presentTrackingMode: $presentTrackingMode
331
                                )
332
                            }
Bogdan Timofte authored 2 weeks ago
333
                        }
Bogdan Timofte authored 2 weeks ago
334
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
335
                    }
Bogdan Timofte authored a week ago
336
                    .frame(height: plotSectionHeight)
Bogdan Timofte authored 2 weeks ago
337
                }
Bogdan Timofte authored 2 weeks ago
338
            } else {
339
                VStack(alignment: .leading, spacing: 12) {
Bogdan Timofte authored 6 days ago
340
                    chartToggleBar()
Bogdan Timofte authored a week ago
341
                    Text("Select at least one measurement series.")
Bogdan Timofte authored 2 weeks ago
342
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
343
                }
344
            }
Bogdan Timofte authored 2 weeks ago
345
        }
Bogdan Timofte authored 6 days ago
346
        .font(chartBaseFont)
Bogdan Timofte authored 2 weeks ago
347
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored a week ago
348
        .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
349
            guard timeRange == nil else { return }
350
            chartNow = now
351
        }
Bogdan Timofte authored 2 weeks ago
352
    }
353

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

            
Bogdan Timofte authored 6 days ago
358
        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
Bogdan Timofte authored a week ago
359
            seriesToggleRow(condensedLayout: condensedLayout)
Bogdan Timofte authored 6 days ago
360
        }
361
        .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
362
        .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
363
        .background(
364
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
365
                .fill(Color.primary.opacity(0.045))
366
        )
367
        .overlay(
368
            RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous)
369
                .stroke(Color.secondary.opacity(0.14), lineWidth: 1)
370
        )
Bogdan Timofte authored a week ago
371

            
Bogdan Timofte authored 6 days ago
372
        return Group {
Bogdan Timofte authored a week ago
373
            if stackedToolbarLayout {
Bogdan Timofte authored 6 days ago
374
                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
375
                    controlsPanel
376
                    HStack {
377
                        Spacer(minLength: 0)
378
                        resetBufferButton(condensedLayout: condensedLayout)
379
                    }
Bogdan Timofte authored 2 weeks ago
380
                }
Bogdan Timofte authored a week ago
381
            } else {
Bogdan Timofte authored 6 days ago
382
                HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
383
                    controlsPanel
Bogdan Timofte authored a week ago
384
                    Spacer(minLength: 0)
385
                    resetBufferButton(condensedLayout: condensedLayout)
Bogdan Timofte authored 2 weeks ago
386
                }
Bogdan Timofte authored a week ago
387
            }
388
        }
389
        .frame(maxWidth: .infinity, alignment: .leading)
390
    }
391

            
Bogdan Timofte authored 6 days ago
392
    private var shouldFloatScaleControlsOverChart: Bool {
393
        #if os(iOS)
394
        if availableSize.width > 0, availableSize.height > 0 {
395
            return availableSize.width > availableSize.height
396
        }
397
        return horizontalSizeClass != .compact && verticalSizeClass == .compact
398
        #else
399
        return false
400
        #endif
401
    }
402

            
403
    private func scaleControlsPill(
404
        voltageSeries: SeriesData,
405
        currentSeries: SeriesData
406
    ) -> some View {
407
        let condensedLayout = compactLayout || verticalSizeClass == .compact
Bogdan Timofte authored 5 days ago
408
        let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart
Bogdan Timofte authored 5 days ago
409
        let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10))
410
        let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8))
Bogdan Timofte authored 6 days ago
411

            
412
        return originControlsRow(
413
            voltageSeries: voltageSeries,
414
            currentSeries: currentSeries,
415
            condensedLayout: condensedLayout,
416
            showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls
417
        )
Bogdan Timofte authored 5 days ago
418
        .padding(.horizontal, horizontalPadding)
419
        .padding(.vertical, verticalPadding)
Bogdan Timofte authored 6 days ago
420
        .background(
421
            Capsule(style: .continuous)
Bogdan Timofte authored 5 days ago
422
                .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear)
Bogdan Timofte authored 6 days ago
423
        )
424
        .overlay(
425
            Capsule(style: .continuous)
426
                .stroke(
Bogdan Timofte authored 5 days ago
427
                    showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear,
Bogdan Timofte authored 6 days ago
428
                    lineWidth: 1
429
                )
430
        )
431
    }
432

            
Bogdan Timofte authored a week ago
433
    private func seriesToggleRow(condensedLayout: Bool) -> some View {
434
        HStack(spacing: condensedLayout ? 6 : 8) {
435
            seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
436
                displayVoltage.toggle()
437
                if displayVoltage {
438
                    displayPower = false
Bogdan Timofte authored 4 days ago
439
                    if displayTemperature && displayCurrent {
440
                        displayCurrent = false
441
                    }
Bogdan Timofte authored a week ago
442
                }
443
            }
444

            
445
            seriesToggleButton(title: "Current", isOn: displayCurrent, condensedLayout: condensedLayout) {
446
                displayCurrent.toggle()
447
                if displayCurrent {
448
                    displayPower = false
Bogdan Timofte authored 4 days ago
449
                    if displayTemperature && displayVoltage {
450
                        displayVoltage = false
451
                    }
Bogdan Timofte authored 2 weeks ago
452
                }
Bogdan Timofte authored a week ago
453
            }
454

            
455
            seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
456
                displayPower.toggle()
457
                if displayPower {
458
                    displayCurrent = false
459
                    displayVoltage = false
460
                }
461
            }
Bogdan Timofte authored 4 days ago
462

            
463
            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
464
                displayTemperature.toggle()
465
                if displayTemperature && displayVoltage && displayCurrent {
466
                    displayCurrent = false
467
                }
468
            }
Bogdan Timofte authored a week ago
469
        }
470
    }
471

            
472
    private func originControlsRow(
473
        voltageSeries: SeriesData,
474
        currentSeries: SeriesData,
Bogdan Timofte authored 6 days ago
475
        condensedLayout: Bool,
476
        showsLabel: Bool
Bogdan Timofte authored a week ago
477
    ) -> some View {
Bogdan Timofte authored 5 days ago
478
        HStack(spacing: usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 8 : 10)) {
479
            if supportsSharedOrigin {
480
                symbolControlChip(
481
                    systemImage: "equal.circle",
482
                    enabled: true,
483
                    active: useSharedOrigin,
484
                    condensedLayout: condensedLayout,
485
                    showsLabel: showsLabel,
486
                    label: "Match Y Scale",
487
                    accessibilityLabel: "Match Y scale"
488
                ) {
489
                    toggleSharedOrigin(
490
                        voltageSeries: voltageSeries,
491
                        currentSeries: currentSeries
492
                    )
493
                }
Bogdan Timofte authored a week ago
494
            }
495

            
496
            symbolControlChip(
497
                systemImage: pinOrigin ? "pin.fill" : "pin.slash",
498
                enabled: true,
499
                active: pinOrigin,
500
                condensedLayout: condensedLayout,
Bogdan Timofte authored 6 days ago
501
                showsLabel: showsLabel,
Bogdan Timofte authored a week ago
502
                label: pinOrigin ? "Origin Locked" : "Origin Auto",
503
                accessibilityLabel: pinOrigin ? "Unlock origin" : "Lock origin"
504
            ) {
505
                togglePinnedOrigin(
506
                    voltageSeries: voltageSeries,
507
                    currentSeries: currentSeries
508
                )
509
            }
510

            
Bogdan Timofte authored 5 days ago
511
            if !pinnedOriginIsZero {
512
                symbolControlChip(
513
                    systemImage: "0.circle",
514
                    enabled: true,
515
                    active: false,
516
                    condensedLayout: condensedLayout,
517
                    showsLabel: showsLabel,
518
                    label: "Origin 0",
519
                    accessibilityLabel: "Set origin to zero"
520
                ) {
521
                    setVisibleOriginsToZero()
522
                }
Bogdan Timofte authored a week ago
523
            }
Bogdan Timofte authored 6 days ago
524

            
Bogdan Timofte authored a week ago
525
        }
526
    }
527

            
528
    private func seriesToggleButton(
529
        title: String,
530
        isOn: Bool,
531
        condensedLayout: Bool,
532
        action: @escaping () -> Void
533
    ) -> some View {
534
        Button(action: action) {
535
            Text(title)
Bogdan Timofte authored 6 days ago
536
                .font(seriesToggleFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
537
                .lineLimit(1)
538
                .minimumScaleFactor(0.82)
539
                .foregroundColor(isOn ? .white : .blue)
Bogdan Timofte authored 6 days ago
540
                .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 16 : 12))
541
                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8))
542
                .frame(minWidth: condensedLayout ? 0 : (isLargeDisplay ? 104 : 84))
Bogdan Timofte authored a week ago
543
                .frame(maxWidth: stackedToolbarLayout ? .infinity : nil)
544
                .background(
545
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
546
                        .fill(isOn ? Color.blue.opacity(0.88) : Color.blue.opacity(0.12))
547
                )
548
                .overlay(
549
                    RoundedRectangle(cornerRadius: condensedLayout ? 13 : 15, style: .continuous)
550
                        .stroke(Color.blue, lineWidth: 1.5)
551
                )
552
        }
553
        .buttonStyle(.plain)
554
    }
555

            
556
    private func symbolControlChip(
557
        systemImage: String,
558
        enabled: Bool,
559
        active: Bool,
560
        condensedLayout: Bool,
561
        showsLabel: Bool,
562
        label: String,
563
        accessibilityLabel: String,
564
        action: @escaping () -> Void
565
    ) -> some View {
566
        Button(action: {
567
            action()
568
        }) {
569
            Group {
570
                if showsLabel {
571
                    Label(label, systemImage: systemImage)
Bogdan Timofte authored 6 days ago
572
                        .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored 5 days ago
573
                        .padding(.horizontal, usesCompactLandscapeOriginControls ? 8 : (condensedLayout ? 10 : 12))
574
                        .padding(.vertical, usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)))
Bogdan Timofte authored a week ago
575
                } else {
576
                    Image(systemName: systemImage)
Bogdan Timofte authored 5 days ago
577
                        .font(.system(size: usesCompactLandscapeOriginControls ? 13 : (condensedLayout ? 15 : (isLargeDisplay ? 18 : 16)), weight: .semibold))
Bogdan Timofte authored 6 days ago
578
                        .frame(
Bogdan Timofte authored 5 days ago
579
                            width: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38)),
580
                            height: usesCompactLandscapeOriginControls ? 30 : (condensedLayout ? 34 : (isLargeDisplay ? 42 : 38))
Bogdan Timofte authored 6 days ago
581
                        )
Bogdan Timofte authored a week ago
582
                }
583
            }
584
                .background(
585
                    Capsule(style: .continuous)
586
                        .fill(active ? Color.orange.opacity(0.18) : Color.secondary.opacity(0.10))
587
                )
588
        }
589
        .buttonStyle(.plain)
590
        .foregroundColor(enabled ? .primary : .secondary)
591
        .opacity(enabled ? 1 : 0.55)
592
        .accessibilityLabel(accessibilityLabel)
593
    }
594

            
595
    private func resetBufferButton(condensedLayout: Bool) -> some View {
596
        Button(action: {
597
            showResetConfirmation = true
598
        }) {
599
            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
Bogdan Timofte authored 6 days ago
600
                .font(controlChipFont(condensedLayout: condensedLayout))
Bogdan Timofte authored a week ago
601
                .padding(.horizontal, condensedLayout ? 14 : 16)
Bogdan Timofte authored 6 days ago
602
                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
Bogdan Timofte authored a week ago
603
        }
604
        .buttonStyle(.plain)
605
        .foregroundColor(.white)
606
        .background(
607
            Capsule(style: .continuous)
608
                .fill(Color.red.opacity(0.8))
609
        )
610
        .fixedSize(horizontal: true, vertical: false)
611
        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
612
            Button("Reset series", role: .destructive) {
613
                measurements.resetSeries()
614
            }
615
            Button("Cancel", role: .cancel) {}
Bogdan Timofte authored 2 weeks ago
616
        }
617
    }
618

            
Bogdan Timofte authored 6 days ago
619
    private func seriesToggleFont(condensedLayout: Bool) -> Font {
620
        if isLargeDisplay {
621
            return .body.weight(.semibold)
622
        }
623
        return (condensedLayout ? Font.callout : .body).weight(.semibold)
624
    }
625

            
626
    private func controlChipFont(condensedLayout: Bool) -> Font {
627
        if isLargeDisplay {
628
            return .callout.weight(.semibold)
629
        }
630
        return (condensedLayout ? Font.callout : .footnote).weight(.semibold)
631
    }
632

            
Bogdan Timofte authored 2 weeks ago
633
    @ViewBuilder
634
    private func primaryAxisView(
635
        height: CGFloat,
Bogdan Timofte authored a week ago
636
        powerSeries: SeriesData,
637
        voltageSeries: SeriesData,
638
        currentSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
639
    ) -> some View {
640
        if displayPower {
641
            yAxisLabelsView(
642
                height: height,
643
                context: powerSeries.context,
Bogdan Timofte authored a week ago
644
                seriesKind: .power,
645
                measurementUnit: powerSeries.kind.unit,
646
                tint: powerSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
647
            )
648
        } else if displayVoltage {
649
            yAxisLabelsView(
650
                height: height,
651
                context: voltageSeries.context,
Bogdan Timofte authored a week ago
652
                seriesKind: .voltage,
653
                measurementUnit: voltageSeries.kind.unit,
654
                tint: voltageSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
655
            )
656
        } else if displayCurrent {
657
            yAxisLabelsView(
658
                height: height,
659
                context: currentSeries.context,
Bogdan Timofte authored a week ago
660
                seriesKind: .current,
661
                measurementUnit: currentSeries.kind.unit,
662
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
663
            )
664
        }
665
    }
666

            
667
    @ViewBuilder
668
    private func renderedChart(
Bogdan Timofte authored a week ago
669
        powerSeries: SeriesData,
670
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
671
        currentSeries: SeriesData,
672
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
673
    ) -> some View {
674
        if self.displayPower {
675
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
676
                .opacity(0.72)
677
        } else {
678
            if self.displayVoltage {
679
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
680
                    .opacity(0.78)
681
            }
682
            if self.displayCurrent {
683
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
684
                    .opacity(0.78)
685
            }
686
        }
Bogdan Timofte authored 4 days ago
687

            
688
        if displayTemperature {
689
            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
690
                .opacity(0.86)
691
        }
Bogdan Timofte authored 2 weeks ago
692
    }
693

            
694
    @ViewBuilder
695
    private func secondaryAxisView(
696
        height: CGFloat,
Bogdan Timofte authored a week ago
697
        powerSeries: SeriesData,
698
        voltageSeries: SeriesData,
Bogdan Timofte authored 4 days ago
699
        currentSeries: SeriesData,
700
        temperatureSeries: SeriesData
Bogdan Timofte authored 2 weeks ago
701
    ) -> some View {
Bogdan Timofte authored 4 days ago
702
        if displayTemperature {
703
            yAxisLabelsView(
704
                height: height,
705
                context: temperatureSeries.context,
706
                seriesKind: .temperature,
707
                measurementUnit: measurementUnit(for: .temperature),
708
                tint: temperatureSeries.kind.tint
709
            )
710
        } else if displayVoltage && displayCurrent {
Bogdan Timofte authored 2 weeks ago
711
            yAxisLabelsView(
712
                height: height,
713
                context: currentSeries.context,
Bogdan Timofte authored a week ago
714
                seriesKind: .current,
715
                measurementUnit: currentSeries.kind.unit,
716
                tint: currentSeries.kind.tint
Bogdan Timofte authored 2 weeks ago
717
            )
718
        } else {
719
            primaryAxisView(
720
                height: height,
721
                powerSeries: powerSeries,
722
                voltageSeries: voltageSeries,
723
                currentSeries: currentSeries
724
            )
Bogdan Timofte authored 2 weeks ago
725
        }
726
    }
Bogdan Timofte authored 2 weeks ago
727

            
728
    private func displayedPrimarySeries(
Bogdan Timofte authored a week ago
729
        powerSeries: SeriesData,
730
        voltageSeries: SeriesData,
731
        currentSeries: SeriesData
732
    ) -> SeriesData? {
Bogdan Timofte authored 2 weeks ago
733
        if displayPower {
Bogdan Timofte authored a week ago
734
            return powerSeries
Bogdan Timofte authored 2 weeks ago
735
        }
736
        if displayVoltage {
Bogdan Timofte authored a week ago
737
            return voltageSeries
Bogdan Timofte authored 2 weeks ago
738
        }
739
        if displayCurrent {
Bogdan Timofte authored a week ago
740
            return currentSeries
Bogdan Timofte authored 2 weeks ago
741
        }
742
        return nil
743
    }
744

            
745
    private func series(
746
        for measurement: Measurements.Measurement,
Bogdan Timofte authored a week ago
747
        kind: SeriesKind,
Bogdan Timofte authored 4 days ago
748
        minimumYSpan: Double,
749
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a week ago
750
    ) -> SeriesData {
Bogdan Timofte authored 4 days ago
751
        let points = filteredPoints(
752
            measurement,
753
            visibleTimeRange: visibleTimeRange
754
        )
Bogdan Timofte authored a week ago
755
        let samplePoints = points.filter { $0.isSample }
Bogdan Timofte authored 2 weeks ago
756
        let context = ChartContext()
Bogdan Timofte authored a week ago
757

            
758
        let autoBounds = automaticYBounds(
759
            for: samplePoints,
760
            minimumYSpan: minimumYSpan
761
        )
Bogdan Timofte authored 4 days ago
762
        let xBounds = xBounds(
763
            for: samplePoints,
764
            visibleTimeRange: visibleTimeRange
765
        )
Bogdan Timofte authored a week ago
766
        let lowerBound = resolvedLowerBound(
767
            for: kind,
768
            autoLowerBound: autoBounds.lowerBound
769
        )
770
        let upperBound = resolvedUpperBound(
771
            for: kind,
772
            lowerBound: lowerBound,
773
            autoUpperBound: autoBounds.upperBound,
774
            maximumSampleValue: samplePoints.map(\.value).max(),
775
            minimumYSpan: minimumYSpan
776
        )
777

            
778
        context.setBounds(
779
            xMin: CGFloat(xBounds.lowerBound.timeIntervalSince1970),
780
            xMax: CGFloat(xBounds.upperBound.timeIntervalSince1970),
781
            yMin: CGFloat(lowerBound),
782
            yMax: CGFloat(upperBound)
783
        )
784

            
785
        return SeriesData(
786
            kind: kind,
787
            points: points,
788
            samplePoints: samplePoints,
789
            context: context,
790
            autoLowerBound: autoBounds.lowerBound,
791
            autoUpperBound: autoBounds.upperBound,
792
            maximumSampleValue: samplePoints.map(\.value).max()
793
        )
794
    }
795

            
Bogdan Timofte authored 4 days ago
796
    private func overviewSeries(for kind: SeriesKind) -> SeriesData {
797
        series(
798
            for: measurement(for: kind),
799
            kind: kind,
800
            minimumYSpan: minimumYSpan(for: kind)
801
        )
802
    }
803

            
804
    private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
805
        switch kind {
806
        case .power:
807
            return measurements.power
808
        case .voltage:
809
            return measurements.voltage
810
        case .current:
811
            return measurements.current
812
        case .temperature:
813
            return measurements.temperature
814
        }
815
    }
816

            
817
    private func minimumYSpan(for kind: SeriesKind) -> Double {
818
        switch kind {
819
        case .power:
820
            return minimumPowerSpan
821
        case .voltage:
822
            return minimumVoltageSpan
823
        case .current:
824
            return minimumCurrentSpan
825
        case .temperature:
826
            return minimumTemperatureSpan
827
        }
828
    }
829

            
Bogdan Timofte authored a week ago
830
    private var supportsSharedOrigin: Bool {
831
        displayVoltage && displayCurrent && !displayPower
832
    }
833

            
Bogdan Timofte authored 6 days ago
834
    private var minimumSharedScaleSpan: Double {
835
        max(minimumVoltageSpan, minimumCurrentSpan)
836
    }
837

            
Bogdan Timofte authored a week ago
838
    private var pinnedOriginIsZero: Bool {
839
        if useSharedOrigin && supportsSharedOrigin {
840
            return pinOrigin && sharedAxisOrigin == 0
Bogdan Timofte authored 2 weeks ago
841
        }
Bogdan Timofte authored a week ago
842

            
843
        if displayPower {
844
            return pinOrigin && powerAxisOrigin == 0
845
        }
846

            
847
        let visibleOrigins = [
848
            displayVoltage ? voltageAxisOrigin : nil,
849
            displayCurrent ? currentAxisOrigin : nil
850
        ]
851
        .compactMap { $0 }
852

            
853
        guard !visibleOrigins.isEmpty else { return false }
854
        return pinOrigin && visibleOrigins.allSatisfy { $0 == 0 }
855
    }
856

            
857
    private func toggleSharedOrigin(
858
        voltageSeries: SeriesData,
859
        currentSeries: SeriesData
860
    ) {
861
        guard supportsSharedOrigin else { return }
862

            
863
        if useSharedOrigin {
864
            useSharedOrigin = false
865
            return
866
        }
867

            
868
        captureCurrentOrigins(
869
            voltageSeries: voltageSeries,
870
            currentSeries: currentSeries
871
        )
872
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
873
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
874
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
875
        useSharedOrigin = true
876
        pinOrigin = true
877
    }
878

            
879
    private func togglePinnedOrigin(
880
        voltageSeries: SeriesData,
881
        currentSeries: SeriesData
882
    ) {
883
        if pinOrigin {
884
            pinOrigin = false
885
            return
886
        }
887

            
888
        captureCurrentOrigins(
889
            voltageSeries: voltageSeries,
890
            currentSeries: currentSeries
891
        )
892
        pinOrigin = true
893
    }
894

            
895
    private func setVisibleOriginsToZero() {
896
        if useSharedOrigin && supportsSharedOrigin {
Bogdan Timofte authored 6 days ago
897
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
898
            sharedAxisOrigin = 0
Bogdan Timofte authored 6 days ago
899
            sharedAxisUpperBound = currentSpan
Bogdan Timofte authored a week ago
900
            voltageAxisOrigin = 0
901
            currentAxisOrigin = 0
Bogdan Timofte authored 6 days ago
902
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
903
        } else {
904
            if displayPower {
905
                powerAxisOrigin = 0
906
            }
907
            if displayVoltage {
908
                voltageAxisOrigin = 0
909
            }
910
            if displayCurrent {
911
                currentAxisOrigin = 0
912
            }
Bogdan Timofte authored 4 days ago
913
            if displayTemperature {
914
                temperatureAxisOrigin = 0
915
            }
Bogdan Timofte authored a week ago
916
        }
917

            
918
        pinOrigin = true
919
    }
920

            
921
    private func captureCurrentOrigins(
922
        voltageSeries: SeriesData,
923
        currentSeries: SeriesData
924
    ) {
925
        powerAxisOrigin = displayedLowerBoundForSeries(.power)
926
        voltageAxisOrigin = voltageSeries.autoLowerBound
927
        currentAxisOrigin = currentSeries.autoLowerBound
Bogdan Timofte authored 4 days ago
928
        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
Bogdan Timofte authored a week ago
929
        sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
Bogdan Timofte authored 6 days ago
930
        sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
931
        ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
932
    }
933

            
934
    private func displayedLowerBoundForSeries(_ kind: SeriesKind) -> Double {
Bogdan Timofte authored 4 days ago
935
        let visibleTimeRange = activeVisibleTimeRange
936

            
Bogdan Timofte authored a week ago
937
        switch kind {
938
        case .power:
Bogdan Timofte authored 4 days ago
939
            return pinOrigin
940
                ? powerAxisOrigin
941
                : automaticYBounds(
942
                    for: filteredSamplePoints(
943
                        measurements.power,
944
                        visibleTimeRange: visibleTimeRange
945
                    ),
946
                    minimumYSpan: minimumPowerSpan
947
                ).lowerBound
Bogdan Timofte authored a week ago
948
        case .voltage:
949
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
950
                return sharedAxisOrigin
951
            }
Bogdan Timofte authored 4 days ago
952
            return pinOrigin
953
                ? voltageAxisOrigin
954
                : automaticYBounds(
955
                    for: filteredSamplePoints(
956
                        measurements.voltage,
957
                        visibleTimeRange: visibleTimeRange
958
                    ),
959
                    minimumYSpan: minimumVoltageSpan
960
                ).lowerBound
Bogdan Timofte authored a week ago
961
        case .current:
962
            if pinOrigin && useSharedOrigin && supportsSharedOrigin {
963
                return sharedAxisOrigin
964
            }
Bogdan Timofte authored 4 days ago
965
            return pinOrigin
966
                ? currentAxisOrigin
967
                : automaticYBounds(
968
                    for: filteredSamplePoints(
969
                        measurements.current,
970
                        visibleTimeRange: visibleTimeRange
971
                    ),
972
                    minimumYSpan: minimumCurrentSpan
973
                ).lowerBound
Bogdan Timofte authored 4 days ago
974
        case .temperature:
Bogdan Timofte authored 4 days ago
975
            return pinOrigin
976
                ? temperatureAxisOrigin
977
                : automaticYBounds(
978
                    for: filteredSamplePoints(
979
                        measurements.temperature,
980
                        visibleTimeRange: visibleTimeRange
981
                    ),
982
                    minimumYSpan: minimumTemperatureSpan
983
                ).lowerBound
Bogdan Timofte authored a week ago
984
        }
985
    }
986

            
Bogdan Timofte authored 4 days ago
987
    private var activeVisibleTimeRange: ClosedRange<Date>? {
988
        resolvedVisibleTimeRange(within: availableSelectionTimeRange())
989
    }
990

            
991
    private func filteredPoints(
992
        _ measurement: Measurements.Measurement,
993
        visibleTimeRange: ClosedRange<Date>? = nil
994
    ) -> [Measurements.Measurement.Point] {
Bogdan Timofte authored a week ago
995
        measurement.points.filter { point in
Bogdan Timofte authored 4 days ago
996
            guard timeRange?.contains(point.timestamp) ?? true else { return false }
997
            return visibleTimeRange?.contains(point.timestamp) ?? true
998
        }
999
    }
1000

            
1001
    private func filteredSamplePoints(
1002
        _ measurement: Measurements.Measurement,
1003
        visibleTimeRange: ClosedRange<Date>? = nil
1004
    ) -> [Measurements.Measurement.Point] {
1005
        filteredPoints(measurement, visibleTimeRange: visibleTimeRange).filter { point in
1006
            point.isSample
Bogdan Timofte authored a week ago
1007
        }
1008
    }
1009

            
1010
    private func xBounds(
Bogdan Timofte authored 4 days ago
1011
        for samplePoints: [Measurements.Measurement.Point],
1012
        visibleTimeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored a week ago
1013
    ) -> ClosedRange<Date> {
Bogdan Timofte authored 4 days ago
1014
        if let visibleTimeRange {
1015
            return normalizedTimeRange(visibleTimeRange)
1016
        }
1017

            
Bogdan Timofte authored a week ago
1018
        if let timeRange {
Bogdan Timofte authored 4 days ago
1019
            return normalizedTimeRange(timeRange)
Bogdan Timofte authored a week ago
1020
        }
1021

            
1022
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1023
        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-defaultEmptyChartTimeSpan)
1024

            
Bogdan Timofte authored 4 days ago
1025
        return normalizedTimeRange(lowerBound...upperBound)
1026
    }
1027

            
1028
    private func availableSelectionTimeRange() -> ClosedRange<Date>? {
1029
        if let timeRange {
1030
            return normalizedTimeRange(timeRange)
1031
        }
1032

            
1033
        let samplePoints = timelineSamplePoints()
1034
        guard let lowerBound = samplePoints.first?.timestamp else {
1035
            return nil
1036
        }
1037

            
1038
        let upperBound = max(samplePoints.last?.timestamp ?? chartNow, chartNow)
1039
        return normalizedTimeRange(lowerBound...upperBound)
1040
    }
1041

            
1042
    private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1043
        let candidates = [
1044
            filteredSamplePoints(measurements.power),
1045
            filteredSamplePoints(measurements.voltage),
1046
            filteredSamplePoints(measurements.current),
1047
            filteredSamplePoints(measurements.temperature)
1048
        ]
1049

            
1050
        return candidates.first(where: { !$0.isEmpty }) ?? []
1051
    }
1052

            
1053
    private func resolvedVisibleTimeRange(
1054
        within availableTimeRange: ClosedRange<Date>?
1055
    ) -> ClosedRange<Date>? {
1056
        guard let availableTimeRange else { return nil }
1057
        guard let selectedVisibleTimeRange else { return availableTimeRange }
1058

            
1059
        if isPinnedToPresent {
1060
            let pinnedRange: ClosedRange<Date>
1061

            
1062
            switch presentTrackingMode {
1063
            case .keepDuration:
1064
                let selectionSpan = selectedVisibleTimeRange.upperBound.timeIntervalSince(selectedVisibleTimeRange.lowerBound)
1065
                pinnedRange = availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
1066
            case .keepStartTimestamp:
1067
                pinnedRange = selectedVisibleTimeRange.lowerBound...availableTimeRange.upperBound
1068
            }
1069

            
1070
            return clampedTimeRange(pinnedRange, within: availableTimeRange)
1071
        }
1072

            
1073
        return clampedTimeRange(selectedVisibleTimeRange, within: availableTimeRange)
1074
    }
1075

            
1076
    private func clampedTimeRange(
1077
        _ candidateRange: ClosedRange<Date>,
1078
        within bounds: ClosedRange<Date>
1079
    ) -> ClosedRange<Date> {
1080
        let normalizedBounds = normalizedTimeRange(bounds)
1081
        let boundsSpan = normalizedBounds.upperBound.timeIntervalSince(normalizedBounds.lowerBound)
1082

            
1083
        guard boundsSpan > 0 else {
1084
            return normalizedBounds
1085
        }
1086

            
1087
        let minimumSpan = min(max(minimumTimeSpan, 0.1), boundsSpan)
1088
        let requestedSpan = min(
1089
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
1090
            boundsSpan
1091
        )
1092

            
1093
        if requestedSpan >= boundsSpan {
1094
            return normalizedBounds
1095
        }
1096

            
1097
        var lowerBound = max(candidateRange.lowerBound, normalizedBounds.lowerBound)
1098
        var upperBound = min(candidateRange.upperBound, normalizedBounds.upperBound)
1099

            
1100
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
1101
            if lowerBound == normalizedBounds.lowerBound {
1102
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
1103
            } else {
1104
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
1105
            }
1106
        }
1107

            
1108
        if upperBound > normalizedBounds.upperBound {
1109
            let delta = upperBound.timeIntervalSince(normalizedBounds.upperBound)
1110
            upperBound = normalizedBounds.upperBound
1111
            lowerBound = lowerBound.addingTimeInterval(-delta)
Bogdan Timofte authored a week ago
1112
        }
1113

            
Bogdan Timofte authored 4 days ago
1114
        if lowerBound < normalizedBounds.lowerBound {
1115
            let delta = normalizedBounds.lowerBound.timeIntervalSince(lowerBound)
1116
            lowerBound = normalizedBounds.lowerBound
1117
            upperBound = upperBound.addingTimeInterval(delta)
1118
        }
1119

            
1120
        return lowerBound...upperBound
1121
    }
1122

            
1123
    private func normalizedTimeRange(_ range: ClosedRange<Date>) -> ClosedRange<Date> {
1124
        let span = range.upperBound.timeIntervalSince(range.lowerBound)
1125
        guard span < minimumTimeSpan else { return range }
1126

            
1127
        let expansion = (minimumTimeSpan - span) / 2
1128
        return range.lowerBound.addingTimeInterval(-expansion)...range.upperBound.addingTimeInterval(expansion)
1129
    }
1130

            
1131
    private func shouldShowRangeSelector(
1132
        availableTimeRange: ClosedRange<Date>,
1133
        series: SeriesData
1134
    ) -> Bool {
1135
        series.samplePoints.count > 1 &&
1136
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) > minimumTimeSpan
Bogdan Timofte authored a week ago
1137
    }
1138

            
1139
    private func automaticYBounds(
1140
        for samplePoints: [Measurements.Measurement.Point],
1141
        minimumYSpan: Double
1142
    ) -> (lowerBound: Double, upperBound: Double) {
1143
        let negativeAllowance = max(0.05, minimumYSpan * 0.08)
1144

            
1145
        guard
1146
            let minimumSampleValue = samplePoints.map(\.value).min(),
1147
            let maximumSampleValue = samplePoints.map(\.value).max()
1148
        else {
1149
            return (0, minimumYSpan)
Bogdan Timofte authored 2 weeks ago
1150
        }
Bogdan Timofte authored a week ago
1151

            
1152
        var lowerBound = minimumSampleValue
1153
        var upperBound = maximumSampleValue
1154
        let currentSpan = upperBound - lowerBound
1155

            
1156
        if currentSpan < minimumYSpan {
1157
            let expansion = (minimumYSpan - currentSpan) / 2
1158
            lowerBound -= expansion
1159
            upperBound += expansion
1160
        }
1161

            
1162
        if minimumSampleValue >= 0, lowerBound < -negativeAllowance {
1163
            let shift = -negativeAllowance - lowerBound
1164
            lowerBound += shift
1165
            upperBound += shift
1166
        }
1167

            
1168
        let snappedLowerBound = snappedOriginValue(lowerBound)
1169
        let resolvedUpperBound = max(upperBound, snappedLowerBound + minimumYSpan)
1170
        return (snappedLowerBound, resolvedUpperBound)
1171
    }
1172

            
1173
    private func resolvedLowerBound(
1174
        for kind: SeriesKind,
1175
        autoLowerBound: Double
1176
    ) -> Double {
1177
        guard pinOrigin else { return autoLowerBound }
1178

            
1179
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1180
            return sharedAxisOrigin
1181
        }
1182

            
1183
        switch kind {
1184
        case .power:
1185
            return powerAxisOrigin
1186
        case .voltage:
1187
            return voltageAxisOrigin
1188
        case .current:
1189
            return currentAxisOrigin
Bogdan Timofte authored 4 days ago
1190
        case .temperature:
1191
            return temperatureAxisOrigin
Bogdan Timofte authored a week ago
1192
        }
1193
    }
1194

            
1195
    private func resolvedUpperBound(
1196
        for kind: SeriesKind,
1197
        lowerBound: Double,
1198
        autoUpperBound: Double,
1199
        maximumSampleValue: Double?,
1200
        minimumYSpan: Double
1201
    ) -> Double {
1202
        guard pinOrigin else {
1203
            return autoUpperBound
1204
        }
1205

            
Bogdan Timofte authored 6 days ago
1206
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1207
            return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1208
        }
1209

            
Bogdan Timofte authored 4 days ago
1210
        if kind == .temperature {
1211
            return autoUpperBound
1212
        }
1213

            
Bogdan Timofte authored a week ago
1214
        return max(
1215
            maximumSampleValue ?? lowerBound,
1216
            lowerBound + minimumYSpan,
1217
            autoUpperBound
1218
        )
1219
    }
1220

            
Bogdan Timofte authored 6 days ago
1221
    private func applyOriginDelta(_ delta: Double, kind: SeriesKind) {
Bogdan Timofte authored a week ago
1222
        let baseline = displayedLowerBoundForSeries(kind)
1223
        let proposedOrigin = snappedOriginValue(baseline + delta)
1224

            
1225
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
Bogdan Timofte authored 6 days ago
1226
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
Bogdan Timofte authored a week ago
1227
            sharedAxisOrigin = min(proposedOrigin, maximumVisibleSharedOrigin())
Bogdan Timofte authored 6 days ago
1228
            sharedAxisUpperBound = sharedAxisOrigin + currentSpan
1229
            ensureSharedScaleSpan()
Bogdan Timofte authored a week ago
1230
        } else {
1231
            switch kind {
1232
            case .power:
1233
                powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
1234
            case .voltage:
1235
                voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1236
            case .current:
1237
                currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
Bogdan Timofte authored 4 days ago
1238
            case .temperature:
1239
                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
Bogdan Timofte authored a week ago
1240
            }
1241
        }
1242

            
1243
        pinOrigin = true
1244
    }
1245

            
Bogdan Timofte authored 6 days ago
1246
    private func clearOriginOffset(for kind: SeriesKind) {
1247
        if useSharedOrigin && supportsSharedOrigin && (kind == .voltage || kind == .current) {
1248
            let currentSpan = max(sharedAxisUpperBound - sharedAxisOrigin, minimumSharedScaleSpan)
1249
            sharedAxisOrigin = 0
1250
            sharedAxisUpperBound = currentSpan
1251
            ensureSharedScaleSpan()
1252
            voltageAxisOrigin = 0
1253
            currentAxisOrigin = 0
1254
        } else {
1255
            switch kind {
1256
            case .power:
1257
                powerAxisOrigin = 0
1258
            case .voltage:
1259
                voltageAxisOrigin = 0
1260
            case .current:
1261
                currentAxisOrigin = 0
Bogdan Timofte authored 4 days ago
1262
            case .temperature:
1263
                temperatureAxisOrigin = 0
Bogdan Timofte authored 6 days ago
1264
            }
1265
        }
1266

            
1267
        pinOrigin = true
1268
    }
1269

            
1270
    private func handleAxisTap(locationY: CGFloat, totalHeight: CGFloat, kind: SeriesKind) {
1271
        guard totalHeight > 1 else { return }
1272

            
1273
        let normalized = max(0, min(1, locationY / totalHeight))
1274
        if normalized < (1.0 / 3.0) {
1275
            applyOriginDelta(-1, kind: kind)
1276
        } else if normalized < (2.0 / 3.0) {
1277
            clearOriginOffset(for: kind)
1278
        } else {
1279
            applyOriginDelta(1, kind: kind)
1280
        }
1281
    }
1282

            
Bogdan Timofte authored a week ago
1283
    private func maximumVisibleOrigin(for kind: SeriesKind) -> Double {
Bogdan Timofte authored 4 days ago
1284
        let visibleTimeRange = activeVisibleTimeRange
1285

            
Bogdan Timofte authored a week ago
1286
        switch kind {
1287
        case .power:
Bogdan Timofte authored 4 days ago
1288
            return snappedOriginValue(
1289
                filteredSamplePoints(
1290
                    measurements.power,
1291
                    visibleTimeRange: visibleTimeRange
1292
                ).map(\.value).min() ?? 0
1293
            )
Bogdan Timofte authored a week ago
1294
        case .voltage:
Bogdan Timofte authored 4 days ago
1295
            return snappedOriginValue(
1296
                filteredSamplePoints(
1297
                    measurements.voltage,
1298
                    visibleTimeRange: visibleTimeRange
1299
                ).map(\.value).min() ?? 0
1300
            )
Bogdan Timofte authored a week ago
1301
        case .current:
Bogdan Timofte authored 4 days ago
1302
            return snappedOriginValue(
1303
                filteredSamplePoints(
1304
                    measurements.current,
1305
                    visibleTimeRange: visibleTimeRange
1306
                ).map(\.value).min() ?? 0
1307
            )
Bogdan Timofte authored 4 days ago
1308
        case .temperature:
Bogdan Timofte authored 4 days ago
1309
            return snappedOriginValue(
1310
                filteredSamplePoints(
1311
                    measurements.temperature,
1312
                    visibleTimeRange: visibleTimeRange
1313
                ).map(\.value).min() ?? 0
1314
            )
Bogdan Timofte authored a week ago
1315
        }
1316
    }
1317

            
1318
    private func maximumVisibleSharedOrigin() -> Double {
1319
        min(
1320
            maximumVisibleOrigin(for: .voltage),
1321
            maximumVisibleOrigin(for: .current)
1322
        )
1323
    }
1324

            
Bogdan Timofte authored 4 days ago
1325
    private func measurementUnit(for kind: SeriesKind) -> String {
1326
        switch kind {
1327
        case .temperature:
1328
            let locale = Locale.autoupdatingCurrent
1329
            if #available(iOS 16.0, *) {
1330
                switch locale.measurementSystem {
1331
                case .us:
1332
                    return "°F"
1333
                default:
1334
                    return "°C"
1335
                }
1336
            }
1337

            
1338
            let regionCode = locale.regionCode ?? ""
1339
            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1340
            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1341
        default:
1342
            return kind.unit
1343
        }
1344
    }
1345

            
Bogdan Timofte authored 6 days ago
1346
    private func ensureSharedScaleSpan() {
1347
        sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
1348
    }
1349

            
Bogdan Timofte authored a week ago
1350
    private func snappedOriginValue(_ value: Double) -> Double {
1351
        if value >= 0 {
1352
            return value.rounded(.down)
1353
        }
1354

            
1355
        return value.rounded(.up)
Bogdan Timofte authored 2 weeks ago
1356
    }
Bogdan Timofte authored 2 weeks ago
1357

            
1358
    private func yGuidePosition(
1359
        for labelIndex: Int,
1360
        context: ChartContext,
1361
        height: CGFloat
1362
    ) -> CGFloat {
1363
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1364
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1365
        return context.placeInRect(point: anchorPoint).y * height
1366
    }
1367

            
1368
    private func xGuidePosition(
1369
        for labelIndex: Int,
1370
        context: ChartContext,
1371
        width: CGFloat
1372
    ) -> CGFloat {
1373
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1374
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1375
        return context.placeInRect(point: anchorPoint).x * width
1376
    }
Bogdan Timofte authored 2 weeks ago
1377

            
1378
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
1379
    fileprivate func xAxisLabelsView(
1380
        context: ChartContext
1381
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
1382
        var timeFormat: String?
1383
        switch context.size.width {
1384
        case 0..<3600: timeFormat = "HH:mm:ss"
1385
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
1386
        default: timeFormat = "E HH:mm"
1387
        }
1388
        let labels = (1...xLabels).map {
1389
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
1390
        }
Bogdan Timofte authored 5 days ago
1391
        let axisLabelFont: Font = {
1392
            if isIPhone && isPortraitLayout {
1393
                return .caption2.weight(.semibold)
1394
            }
1395
            return (isLargeDisplay ? Font.callout : .caption).weight(.semibold)
1396
        }()
Bogdan Timofte authored 2 weeks ago
1397

            
1398
        return HStack(spacing: chartSectionSpacing) {
1399
            Color.clear
1400
                .frame(width: axisColumnWidth)
1401

            
1402
            GeometryReader { geometry in
1403
                let labelWidth = max(
1404
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
1405
                    1
1406
                )
1407

            
1408
                ZStack(alignment: .topLeading) {
1409
                    Path { path in
1410
                        for labelIndex in 1...self.xLabels {
1411
                            let x = xGuidePosition(
1412
                                for: labelIndex,
1413
                                context: context,
1414
                                width: geometry.size.width
1415
                            )
1416
                            path.move(to: CGPoint(x: x, y: 0))
1417
                            path.addLine(to: CGPoint(x: x, y: 6))
1418
                        }
1419
                    }
1420
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
1421

            
1422
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
1423
                        let labelIndex = item.offset + 1
1424
                        let centerX = xGuidePosition(
1425
                            for: labelIndex,
1426
                            context: context,
1427
                            width: geometry.size.width
1428
                        )
1429

            
1430
                        Text(item.element)
Bogdan Timofte authored 5 days ago
1431
                            .font(axisLabelFont)
Bogdan Timofte authored 2 weeks ago
1432
                            .monospacedDigit()
1433
                            .lineLimit(1)
Bogdan Timofte authored 6 days ago
1434
                            .minimumScaleFactor(0.74)
Bogdan Timofte authored 2 weeks ago
1435
                            .frame(width: labelWidth)
1436
                            .position(
1437
                                x: centerX,
1438
                                y: geometry.size.height * 0.7
1439
                            )
Bogdan Timofte authored 2 weeks ago
1440
                    }
1441
                }
Bogdan Timofte authored 2 weeks ago
1442
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
1443
            }
Bogdan Timofte authored 2 weeks ago
1444

            
1445
            Color.clear
1446
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
1447
        }
1448
    }
1449

            
Bogdan Timofte authored a week ago
1450
    private func yAxisLabelsView(
Bogdan Timofte authored 2 weeks ago
1451
        height: CGFloat,
1452
        context: ChartContext,
Bogdan Timofte authored a week ago
1453
        seriesKind: SeriesKind,
Bogdan Timofte authored 2 weeks ago
1454
        measurementUnit: String,
1455
        tint: Color
1456
    ) -> some View {
Bogdan Timofte authored 5 days ago
1457
        let yAxisFont: Font = {
1458
            if isIPhone && isPortraitLayout {
1459
                return .caption2.weight(.semibold)
1460
            }
1461
            return (isLargeDisplay ? Font.callout : .footnote).weight(.semibold)
1462
        }()
1463

            
1464
        let unitFont: Font = {
1465
            if isIPhone && isPortraitLayout {
1466
                return .caption2.weight(.bold)
1467
            }
1468
            return (isLargeDisplay ? Font.footnote : .caption2).weight(.bold)
1469
        }()
1470

            
1471
        return GeometryReader { geometry in
Bogdan Timofte authored 6 days ago
1472
            let footerHeight: CGFloat = isLargeDisplay ? 30 : 24
1473
            let topInset: CGFloat = isLargeDisplay ? 34 : 28
1474
            let labelAreaHeight = max(geometry.size.height - footerHeight - topInset, 1)
1475

            
Bogdan Timofte authored 2 weeks ago
1476
            ZStack(alignment: .top) {
1477
                ForEach(0..<yLabels, id: \.self) { row in
1478
                    let labelIndex = yLabels - row
1479

            
1480
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
Bogdan Timofte authored 5 days ago
1481
                        .font(yAxisFont)
Bogdan Timofte authored 2 weeks ago
1482
                        .monospacedDigit()
1483
                        .lineLimit(1)
Bogdan Timofte authored 6 days ago
1484
                        .minimumScaleFactor(0.8)
1485
                        .frame(width: max(geometry.size.width - 10, 0))
Bogdan Timofte authored 2 weeks ago
1486
                        .position(
1487
                            x: geometry.size.width / 2,
Bogdan Timofte authored 6 days ago
1488
                            y: topInset + yGuidePosition(
Bogdan Timofte authored 2 weeks ago
1489
                                for: labelIndex,
1490
                                context: context,
Bogdan Timofte authored 6 days ago
1491
                                height: labelAreaHeight
Bogdan Timofte authored 2 weeks ago
1492
                            )
1493
                        )
Bogdan Timofte authored 2 weeks ago
1494
                }
Bogdan Timofte authored 2 weeks ago
1495

            
Bogdan Timofte authored 2 weeks ago
1496
                Text(measurementUnit)
Bogdan Timofte authored 5 days ago
1497
                    .font(unitFont)
Bogdan Timofte authored 2 weeks ago
1498
                    .foregroundColor(tint)
Bogdan Timofte authored 6 days ago
1499
                    .padding(.horizontal, isLargeDisplay ? 8 : 6)
1500
                    .padding(.vertical, isLargeDisplay ? 5 : 4)
Bogdan Timofte authored 2 weeks ago
1501
                    .background(
1502
                        Capsule(style: .continuous)
1503
                            .fill(tint.opacity(0.14))
1504
                    )
Bogdan Timofte authored 6 days ago
1505
                    .padding(.top, 8)
1506

            
Bogdan Timofte authored 2 weeks ago
1507
            }
1508
        }
Bogdan Timofte authored 2 weeks ago
1509
        .frame(height: height)
1510
        .background(
1511
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1512
                .fill(tint.opacity(0.12))
1513
        )
1514
        .overlay(
1515
            RoundedRectangle(cornerRadius: 16, style: .continuous)
1516
                .stroke(tint.opacity(0.20), lineWidth: 1)
1517
        )
Bogdan Timofte authored a week ago
1518
        .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
1519
        .gesture(
Bogdan Timofte authored 6 days ago
1520
            DragGesture(minimumDistance: 0)
Bogdan Timofte authored a week ago
1521
                .onEnded { value in
Bogdan Timofte authored 6 days ago
1522
                    handleAxisTap(locationY: value.location.y, totalHeight: height, kind: seriesKind)
Bogdan Timofte authored a week ago
1523
                }
1524
        )
Bogdan Timofte authored 2 weeks ago
1525
    }
1526

            
Bogdan Timofte authored 2 weeks ago
1527
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1528
        GeometryReader { geometry in
1529
            Path { path in
Bogdan Timofte authored 2 weeks ago
1530
                for labelIndex in 1...self.yLabels {
1531
                    let y = yGuidePosition(
1532
                        for: labelIndex,
1533
                        context: context,
1534
                        height: geometry.size.height
1535
                    )
1536
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
1537
                }
Bogdan Timofte authored 2 weeks ago
1538
            }
1539
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
1540
        }
1541
    }
1542

            
Bogdan Timofte authored 2 weeks ago
1543
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
1544
        GeometryReader { geometry in
1545
            Path { path in
1546

            
Bogdan Timofte authored 2 weeks ago
1547
                for labelIndex in 2..<self.xLabels {
1548
                    let x = xGuidePosition(
1549
                        for: labelIndex,
1550
                        context: context,
1551
                        width: geometry.size.width
1552
                    )
1553
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
1554
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1555
                }
Bogdan Timofte authored 2 weeks ago
1556
            }
1557
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
1558
        }
1559
    }
Bogdan Timofte authored a week ago
1560

            
1561
    fileprivate func discontinuityMarkers(
1562
        points: [Measurements.Measurement.Point],
1563
        context: ChartContext
1564
    ) -> some View {
1565
        GeometryReader { geometry in
1566
            Path { path in
1567
                for point in points where point.isDiscontinuity {
1568
                    let markerX = context.placeInRect(
1569
                        point: CGPoint(
1570
                            x: point.timestamp.timeIntervalSince1970,
1571
                            y: context.origin.y
1572
                        )
1573
                    ).x * geometry.size.width
1574
                    path.move(to: CGPoint(x: markerX, y: 0))
1575
                    path.addLine(to: CGPoint(x: markerX, y: geometry.size.height))
1576
                }
1577
            }
1578
            .stroke(Color.orange.opacity(0.75), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
1579
        }
1580
    }
Bogdan Timofte authored 2 weeks ago
1581

            
1582
}
1583

            
Bogdan Timofte authored 4 days ago
1584
private struct TimeRangeSelectorView: View {
1585
    private enum DragTarget {
1586
        case lowerBound
1587
        case upperBound
1588
        case window
1589
    }
1590

            
1591
    private struct DragState {
1592
        let target: DragTarget
1593
        let initialRange: ClosedRange<Date>
1594
    }
1595

            
1596
    let points: [Measurements.Measurement.Point]
1597
    let context: ChartContext
1598
    let availableTimeRange: ClosedRange<Date>
1599
    let accentColor: Color
1600
    let compactLayout: Bool
1601
    let minimumSelectionSpan: TimeInterval
1602

            
1603
    @Binding var selectedTimeRange: ClosedRange<Date>?
1604
    @Binding var isPinnedToPresent: Bool
1605
    @Binding var presentTrackingMode: PresentTrackingMode
1606
    @State private var dragState: DragState?
1607

            
1608
    private var totalSpan: TimeInterval {
1609
        availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
1610
    }
1611

            
1612
    private var currentRange: ClosedRange<Date> {
1613
        resolvedSelectionRange()
1614
    }
1615

            
1616
    private var trackHeight: CGFloat {
1617
        compactLayout ? 72 : 86
1618
    }
1619

            
1620
    private var cornerRadius: CGFloat {
1621
        compactLayout ? 14 : 16
1622
    }
1623

            
1624
    private var summaryFont: Font {
1625
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1626
    }
1627

            
1628
    private var boundaryFont: Font {
1629
        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1630
    }
1631

            
1632
    private var symbolButtonSize: CGFloat {
1633
        compactLayout ? 28 : 32
1634
    }
1635

            
1636
    var body: some View {
1637
        let coversFullRange = selectionCoversFullRange(currentRange)
1638

            
1639
        VStack(alignment: .leading, spacing: compactLayout ? 6 : 8) {
1640
            if !coversFullRange || isPinnedToPresent {
1641
                HStack(spacing: 8) {
1642
                    alignmentButton(
1643
                        systemName: "arrow.left.to.line.compact",
1644
                        isActive: currentRange.lowerBound == availableTimeRange.lowerBound && !isPinnedToPresent,
1645
                        action: alignSelectionToLeadingEdge,
1646
                        accessibilityLabel: "Align selection to start"
1647
                    )
1648

            
1649
                    alignmentButton(
1650
                        systemName: "arrow.right.to.line.compact",
1651
                        isActive: isPinnedToPresent || selectionTouchesPresent(currentRange),
1652
                        action: alignSelectionToTrailingEdge,
1653
                        accessibilityLabel: "Align selection to present"
1654
                    )
1655

            
1656
                    Spacer(minLength: 0)
1657

            
1658
                    if isPinnedToPresent {
1659
                        trackingModeToggleButton()
1660
                    }
1661
                }
1662
            }
1663

            
1664
            GeometryReader { geometry in
1665
                let selectionFrame = selectionFrame(in: geometry.size)
1666
                let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
1667

            
1668
                ZStack(alignment: .topLeading) {
1669
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1670
                        .fill(Color.primary.opacity(0.05))
1671

            
1672
                    Chart(
1673
                        points: points,
1674
                        context: context,
1675
                        areaChart: true,
1676
                        strokeColor: accentColor,
1677
                        areaFillColor: accentColor.opacity(0.22)
1678
                    )
1679
                    .opacity(0.94)
1680
                    .allowsHitTesting(false)
1681

            
1682
                    Chart(
1683
                        points: points,
1684
                        context: context,
1685
                        strokeColor: accentColor.opacity(0.56)
1686
                    )
1687
                    .opacity(0.82)
1688
                    .allowsHitTesting(false)
1689

            
1690
                    if selectionFrame.minX > 0 {
1691
                        Rectangle()
1692
                            .fill(dimmingColor)
1693
                            .frame(width: selectionFrame.minX, height: geometry.size.height)
1694
                            .allowsHitTesting(false)
1695
                    }
1696

            
1697
                    if selectionFrame.maxX < geometry.size.width {
1698
                        Rectangle()
1699
                            .fill(dimmingColor)
1700
                            .frame(
1701
                                width: max(geometry.size.width - selectionFrame.maxX, 0),
1702
                                height: geometry.size.height
1703
                            )
1704
                            .offset(x: selectionFrame.maxX)
1705
                            .allowsHitTesting(false)
1706
                    }
1707

            
1708
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
1709
                        .fill(accentColor.opacity(0.18))
1710
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
1711
                        .offset(x: selectionFrame.minX)
1712
                        .allowsHitTesting(false)
1713

            
1714
                    RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
1715
                        .stroke(accentColor.opacity(0.52), lineWidth: 1.2)
1716
                        .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
1717
                        .offset(x: selectionFrame.minX)
1718
                        .allowsHitTesting(false)
1719

            
1720
                    handleView(height: max(geometry.size.height - 18, 16))
1721
                        .offset(x: max(selectionFrame.minX + 6, 6), y: 9)
1722
                        .allowsHitTesting(false)
1723

            
1724
                    handleView(height: max(geometry.size.height - 18, 16))
1725
                        .offset(x: max(selectionFrame.maxX - 12, 6), y: 9)
1726
                        .allowsHitTesting(false)
1727
                }
1728
                .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
1729
                .overlay(
1730
                    RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
1731
                        .stroke(Color.secondary.opacity(0.18), lineWidth: 1)
1732
                )
1733
                .contentShape(Rectangle())
1734
                .gesture(selectionGesture(totalWidth: geometry.size.width))
1735
            }
1736
            .frame(height: trackHeight)
1737

            
1738
            HStack {
1739
                Text(boundaryLabel(for: availableTimeRange.lowerBound))
1740
                Spacer(minLength: 0)
1741
                Text(boundaryLabel(for: availableTimeRange.upperBound))
1742
            }
1743
            .font(boundaryFont)
1744
            .foregroundColor(.secondary)
1745
            .monospacedDigit()
1746
        }
1747
    }
1748

            
1749
    private func handleView(height: CGFloat) -> some View {
1750
        Capsule(style: .continuous)
1751
            .fill(Color.white.opacity(0.95))
1752
            .frame(width: 6, height: height)
1753
            .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1)
1754
    }
1755

            
1756
    private func alignmentButton(
1757
        systemName: String,
1758
        isActive: Bool,
1759
        action: @escaping () -> Void,
1760
        accessibilityLabel: String
1761
    ) -> some View {
1762
        Button(action: action) {
1763
            Image(systemName: systemName)
1764
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
1765
                .frame(width: symbolButtonSize, height: symbolButtonSize)
1766
        }
1767
        .buttonStyle(.plain)
1768
        .foregroundColor(isActive ? .white : accentColor)
1769
        .background(
1770
            RoundedRectangle(cornerRadius: 9, style: .continuous)
1771
                .fill(isActive ? accentColor : accentColor.opacity(0.14))
1772
        )
1773
        .overlay(
1774
            RoundedRectangle(cornerRadius: 9, style: .continuous)
1775
                .stroke(accentColor.opacity(0.28), lineWidth: 1)
1776
        )
1777
        .accessibilityLabel(accessibilityLabel)
1778
    }
1779

            
1780
    private func trackingModeToggleButton() -> some View {
1781
        Button {
1782
            presentTrackingMode = presentTrackingMode == .keepDuration
1783
                ? .keepStartTimestamp
1784
                : .keepDuration
1785
        } label: {
1786
            Image(systemName: trackingModeSymbolName)
1787
                .font(.system(size: compactLayout ? 12 : 13, weight: .semibold))
1788
                .frame(width: symbolButtonSize, height: symbolButtonSize)
1789
        }
1790
        .buttonStyle(.plain)
1791
        .foregroundColor(.white)
1792
        .background(
1793
            RoundedRectangle(cornerRadius: 9, style: .continuous)
1794
                .fill(accentColor)
1795
        )
1796
        .overlay(
1797
            RoundedRectangle(cornerRadius: 9, style: .continuous)
1798
                .stroke(accentColor.opacity(0.28), lineWidth: 1)
1799
        )
1800
        .accessibilityLabel(trackingModeAccessibilityLabel)
1801
        .accessibilityHint("Toggles how the interval follows the present")
1802
    }
1803

            
1804
    private var trackingModeSymbolName: String {
1805
        switch presentTrackingMode {
1806
        case .keepDuration:
1807
            return "arrow.left.and.right"
1808
        case .keepStartTimestamp:
1809
            return "arrow.left.to.line.compact"
1810
        }
1811
    }
1812

            
1813
    private var trackingModeAccessibilityLabel: String {
1814
        switch presentTrackingMode {
1815
        case .keepDuration:
1816
            return "Follow present keeping span"
1817
        case .keepStartTimestamp:
1818
            return "Follow present keeping start"
1819
        }
1820
    }
1821

            
1822
    private func alignSelectionToLeadingEdge() {
1823
        let alignedRange = normalizedSelectionRange(
1824
            availableTimeRange.lowerBound...currentRange.upperBound
1825
        )
1826
        applySelection(alignedRange, pinToPresent: false)
1827
    }
1828

            
1829
    private func alignSelectionToTrailingEdge() {
1830
        let alignedRange = normalizedSelectionRange(
1831
            currentRange.lowerBound...availableTimeRange.upperBound
1832
        )
1833
        applySelection(alignedRange, pinToPresent: true)
1834
    }
1835

            
1836
    private func selectionGesture(totalWidth: CGFloat) -> some Gesture {
1837
        DragGesture(minimumDistance: 0)
1838
            .onChanged { value in
1839
                updateSelectionDrag(value: value, totalWidth: totalWidth)
1840
            }
1841
            .onEnded { _ in
1842
                dragState = nil
1843
            }
1844
    }
1845

            
1846
    private func updateSelectionDrag(
1847
        value: DragGesture.Value,
1848
        totalWidth: CGFloat
1849
    ) {
1850
        let startingRange = resolvedSelectionRange()
1851

            
1852
        if dragState == nil {
1853
            dragState = DragState(
1854
                target: dragTarget(
1855
                    for: value.startLocation.x,
1856
                    selectionFrame: selectionFrame(for: startingRange, width: totalWidth)
1857
                ),
1858
                initialRange: startingRange
1859
            )
1860
        }
1861

            
1862
        guard let dragState else { return }
1863

            
1864
        let resultingRange = snappedToEdges(
1865
            adjustedRange(
1866
                from: dragState.initialRange,
1867
                target: dragState.target,
1868
                translationX: value.translation.width,
1869
                totalWidth: totalWidth
1870
            ),
1871
            target: dragState.target,
1872
            totalWidth: totalWidth
1873
        )
1874

            
1875
        applySelection(
1876
            resultingRange,
1877
            pinToPresent: shouldKeepPresentPin(
1878
                during: dragState.target,
1879
                initialRange: dragState.initialRange,
1880
                resultingRange: resultingRange
1881
            ),
1882
        )
1883
    }
1884

            
1885
    private func dragTarget(
1886
        for startX: CGFloat,
1887
        selectionFrame: CGRect
1888
    ) -> DragTarget {
1889
        let handleZone: CGFloat = compactLayout ? 20 : 24
1890

            
1891
        if abs(startX - selectionFrame.minX) <= handleZone {
1892
            return .lowerBound
1893
        }
1894

            
1895
        if abs(startX - selectionFrame.maxX) <= handleZone {
1896
            return .upperBound
1897
        }
1898

            
1899
        if selectionFrame.contains(CGPoint(x: startX, y: selectionFrame.midY)) {
1900
            return .window
1901
        }
1902

            
1903
        return startX < selectionFrame.minX ? .lowerBound : .upperBound
1904
    }
1905

            
1906
    private func adjustedRange(
1907
        from initialRange: ClosedRange<Date>,
1908
        target: DragTarget,
1909
        translationX: CGFloat,
1910
        totalWidth: CGFloat
1911
    ) -> ClosedRange<Date> {
1912
        guard totalSpan > 0, totalWidth > 0 else {
1913
            return availableTimeRange
1914
        }
1915

            
1916
        let delta = TimeInterval(translationX / totalWidth) * totalSpan
1917
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), totalSpan)
1918

            
1919
        switch target {
1920
        case .lowerBound:
1921
            let maximumLowerBound = initialRange.upperBound.addingTimeInterval(-minimumSpan)
1922
            let newLowerBound = min(
1923
                max(initialRange.lowerBound.addingTimeInterval(delta), availableTimeRange.lowerBound),
1924
                maximumLowerBound
1925
            )
1926
            return normalizedSelectionRange(newLowerBound...initialRange.upperBound)
1927

            
1928
        case .upperBound:
1929
            let minimumUpperBound = initialRange.lowerBound.addingTimeInterval(minimumSpan)
1930
            let newUpperBound = max(
1931
                min(initialRange.upperBound.addingTimeInterval(delta), availableTimeRange.upperBound),
1932
                minimumUpperBound
1933
            )
1934
            return normalizedSelectionRange(initialRange.lowerBound...newUpperBound)
1935

            
1936
        case .window:
1937
            let span = initialRange.upperBound.timeIntervalSince(initialRange.lowerBound)
1938
            guard span < totalSpan else { return availableTimeRange }
1939

            
1940
            var lowerBound = initialRange.lowerBound.addingTimeInterval(delta)
1941
            var upperBound = initialRange.upperBound.addingTimeInterval(delta)
1942

            
1943
            if lowerBound < availableTimeRange.lowerBound {
1944
                upperBound = upperBound.addingTimeInterval(
1945
                    availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
1946
                )
1947
                lowerBound = availableTimeRange.lowerBound
1948
            }
1949

            
1950
            if upperBound > availableTimeRange.upperBound {
1951
                lowerBound = lowerBound.addingTimeInterval(
1952
                    -upperBound.timeIntervalSince(availableTimeRange.upperBound)
1953
                )
1954
                upperBound = availableTimeRange.upperBound
1955
            }
1956

            
1957
            return normalizedSelectionRange(lowerBound...upperBound)
1958
        }
1959
    }
1960

            
1961
    private func snappedToEdges(
1962
        _ candidateRange: ClosedRange<Date>,
1963
        target: DragTarget,
1964
        totalWidth: CGFloat
1965
    ) -> ClosedRange<Date> {
1966
        guard totalSpan > 0 else {
1967
            return availableTimeRange
1968
        }
1969

            
1970
        let snapInterval = edgeSnapInterval(for: totalWidth)
1971
        let selectionSpan = candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound)
1972
        var lowerBound = candidateRange.lowerBound
1973
        var upperBound = candidateRange.upperBound
1974

            
1975
        if target != .upperBound,
1976
           lowerBound.timeIntervalSince(availableTimeRange.lowerBound) <= snapInterval {
1977
            lowerBound = availableTimeRange.lowerBound
1978
            if target == .window {
1979
                upperBound = lowerBound.addingTimeInterval(selectionSpan)
1980
            }
1981
        }
1982

            
1983
        if target != .lowerBound,
1984
           availableTimeRange.upperBound.timeIntervalSince(upperBound) <= snapInterval {
1985
            upperBound = availableTimeRange.upperBound
1986
            if target == .window {
1987
                lowerBound = upperBound.addingTimeInterval(-selectionSpan)
1988
            }
1989
        }
1990

            
1991
        return normalizedSelectionRange(lowerBound...upperBound)
1992
    }
1993

            
1994
    private func edgeSnapInterval(
1995
        for totalWidth: CGFloat
1996
    ) -> TimeInterval {
1997
        guard totalWidth > 0 else { return minimumSelectionSpan }
1998

            
1999
        let snapWidth = min(
2000
            max(compactLayout ? 18 : 22, totalWidth * 0.04),
2001
            totalWidth * 0.18
2002
        )
2003
        let intervalFromWidth = TimeInterval(snapWidth / totalWidth) * totalSpan
2004
        return min(
2005
            max(intervalFromWidth, minimumSelectionSpan * 0.5),
2006
            totalSpan / 4
2007
        )
2008
    }
2009

            
2010
    private func resolvedSelectionRange() -> ClosedRange<Date> {
2011
        guard let selectedTimeRange else { return availableTimeRange }
2012

            
2013
        if isPinnedToPresent {
2014
            switch presentTrackingMode {
2015
            case .keepDuration:
2016
                let selectionSpan = selectedTimeRange.upperBound.timeIntervalSince(selectedTimeRange.lowerBound)
2017
                return normalizedSelectionRange(
2018
                    availableTimeRange.upperBound.addingTimeInterval(-selectionSpan)...availableTimeRange.upperBound
2019
                )
2020
            case .keepStartTimestamp:
2021
                return normalizedSelectionRange(
2022
                    selectedTimeRange.lowerBound...availableTimeRange.upperBound
2023
                )
2024
            }
2025
        }
2026

            
2027
        return normalizedSelectionRange(selectedTimeRange)
2028
    }
2029

            
2030
    private func normalizedSelectionRange(
2031
        _ candidateRange: ClosedRange<Date>
2032
    ) -> ClosedRange<Date> {
2033
        let availableSpan = totalSpan
2034
        guard availableSpan > 0 else { return availableTimeRange }
2035

            
2036
        let minimumSpan = min(max(minimumSelectionSpan, 0.1), availableSpan)
2037
        let requestedSpan = min(
2038
            max(candidateRange.upperBound.timeIntervalSince(candidateRange.lowerBound), minimumSpan),
2039
            availableSpan
2040
        )
2041

            
2042
        if requestedSpan >= availableSpan {
2043
            return availableTimeRange
2044
        }
2045

            
2046
        var lowerBound = max(candidateRange.lowerBound, availableTimeRange.lowerBound)
2047
        var upperBound = min(candidateRange.upperBound, availableTimeRange.upperBound)
2048

            
2049
        if upperBound.timeIntervalSince(lowerBound) < requestedSpan {
2050
            if lowerBound == availableTimeRange.lowerBound {
2051
                upperBound = lowerBound.addingTimeInterval(requestedSpan)
2052
            } else {
2053
                lowerBound = upperBound.addingTimeInterval(-requestedSpan)
2054
            }
2055
        }
2056

            
2057
        if upperBound > availableTimeRange.upperBound {
2058
            let delta = upperBound.timeIntervalSince(availableTimeRange.upperBound)
2059
            upperBound = availableTimeRange.upperBound
2060
            lowerBound = lowerBound.addingTimeInterval(-delta)
2061
        }
2062

            
2063
        if lowerBound < availableTimeRange.lowerBound {
2064
            let delta = availableTimeRange.lowerBound.timeIntervalSince(lowerBound)
2065
            lowerBound = availableTimeRange.lowerBound
2066
            upperBound = upperBound.addingTimeInterval(delta)
2067
        }
2068

            
2069
        return lowerBound...upperBound
2070
    }
2071

            
2072
    private func shouldKeepPresentPin(
2073
        during target: DragTarget,
2074
        initialRange: ClosedRange<Date>,
2075
        resultingRange: ClosedRange<Date>
2076
    ) -> Bool {
2077
        let startedPinnedToPresent =
2078
            isPinnedToPresent ||
2079
            selectionCoversFullRange(initialRange)
2080

            
2081
        guard startedPinnedToPresent else {
2082
            return selectionTouchesPresent(resultingRange)
2083
        }
2084

            
2085
        switch target {
2086
        case .lowerBound:
2087
            return true
2088
        case .upperBound, .window:
2089
            return selectionTouchesPresent(resultingRange)
2090
        }
2091
    }
2092

            
2093
    private func applySelection(
2094
        _ candidateRange: ClosedRange<Date>,
2095
        pinToPresent: Bool
2096
    ) {
2097
        let normalizedRange = normalizedSelectionRange(candidateRange)
2098

            
2099
        if selectionCoversFullRange(normalizedRange) && !pinToPresent {
2100
            selectedTimeRange = nil
2101
        } else {
2102
            selectedTimeRange = normalizedRange
2103
        }
2104

            
2105
        isPinnedToPresent = pinToPresent
2106
    }
2107

            
2108
    private func selectionTouchesPresent(
2109
        _ range: ClosedRange<Date>
2110
    ) -> Bool {
2111
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2112
        return abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2113
    }
2114

            
2115
    private func selectionCoversFullRange(
2116
        _ range: ClosedRange<Date>
2117
    ) -> Bool {
2118
        let tolerance = max(0.5, minimumSelectionSpan * 0.25)
2119
        return abs(range.lowerBound.timeIntervalSince(availableTimeRange.lowerBound)) <= tolerance &&
2120
        abs(range.upperBound.timeIntervalSince(availableTimeRange.upperBound)) <= tolerance
2121
    }
2122

            
2123
    private func selectionFrame(in size: CGSize) -> CGRect {
2124
        selectionFrame(for: currentRange, width: size.width)
2125
    }
2126

            
2127
    private func selectionFrame(
2128
        for range: ClosedRange<Date>,
2129
        width: CGFloat
2130
    ) -> CGRect {
2131
        guard width > 0, totalSpan > 0 else {
2132
            return CGRect(origin: .zero, size: CGSize(width: width, height: trackHeight))
2133
        }
2134

            
2135
        let minimumX = xPosition(for: range.lowerBound, width: width)
2136
        let maximumX = xPosition(for: range.upperBound, width: width)
2137
        return CGRect(
2138
            x: minimumX,
2139
            y: 0,
2140
            width: max(maximumX - minimumX, 2),
2141
            height: trackHeight
2142
        )
2143
    }
2144

            
2145
    private func xPosition(for date: Date, width: CGFloat) -> CGFloat {
2146
        guard width > 0, totalSpan > 0 else { return 0 }
2147

            
2148
        let offset = date.timeIntervalSince(availableTimeRange.lowerBound)
2149
        let normalizedOffset = min(max(offset / totalSpan, 0), 1)
2150
        return CGFloat(normalizedOffset) * width
2151
    }
2152

            
2153
    private func boundaryLabel(for date: Date) -> String {
2154
        date.format(as: boundaryDateFormat)
2155
    }
2156

            
2157
    private func selectionSummary(
2158
        for range: ClosedRange<Date>
2159
    ) -> String {
2160
        "\(range.lowerBound.format(as: summaryDateFormat)) - \(range.upperBound.format(as: summaryDateFormat))"
2161
    }
2162

            
2163
    private var boundaryDateFormat: String {
2164
        switch totalSpan {
2165
        case 0..<86400:
2166
            return "HH:mm"
2167
        case 86400..<604800:
2168
            return "MMM d HH:mm"
2169
        default:
2170
            return "MMM d"
2171
        }
2172
    }
2173

            
2174
    private var summaryDateFormat: String {
2175
        switch totalSpan {
2176
        case 0..<3600:
2177
            return "HH:mm:ss"
2178
        case 3600..<172800:
2179
            return "MMM d HH:mm"
2180
        default:
2181
            return "MMM d"
2182
        }
2183
    }
2184
}
2185

            
Bogdan Timofte authored 2 weeks ago
2186
struct Chart : View {
2187

            
Bogdan Timofte authored 2 weeks ago
2188
    let points: [Measurements.Measurement.Point]
2189
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
2190
    var areaChart: Bool = false
2191
    var strokeColor: Color = .black
Bogdan Timofte authored 4 days ago
2192
    var areaFillColor: Color? = nil
Bogdan Timofte authored 2 weeks ago
2193

            
2194
    var body : some View {
2195
        GeometryReader { geometry in
2196
            if self.areaChart {
Bogdan Timofte authored 4 days ago
2197
                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
Bogdan Timofte authored 2 weeks ago
2198
                self.path( geometry: geometry )
Bogdan Timofte authored 4 days ago
2199
                    .fill(
2200
                        LinearGradient(
2201
                            gradient: .init(
2202
                                colors: [
2203
                                    fillColor.opacity(0.72),
2204
                                    fillColor.opacity(0.18)
2205
                                ]
2206
                            ),
2207
                            startPoint: .init(x: 0.5, y: 0.08),
2208
                            endPoint: .init(x: 0.5, y: 0.92)
2209
                        )
2210
                    )
Bogdan Timofte authored 2 weeks ago
2211
            } else {
2212
                self.path( geometry: geometry )
2213
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2214
            }
2215
        }
2216
    }
2217

            
2218
    fileprivate func path(geometry: GeometryProxy) -> Path {
2219
        return Path { path in
Bogdan Timofte authored a week ago
2220
            var firstSample: Measurements.Measurement.Point?
2221
            var lastSample: Measurements.Measurement.Point?
2222
            var needsMove = true
2223

            
2224
            for point in points {
2225
                if point.isDiscontinuity {
2226
                    needsMove = true
2227
                    continue
2228
                }
2229

            
2230
                let item = context.placeInRect(point: point.point())
2231
                let renderedPoint = CGPoint(
2232
                    x: item.x * geometry.size.width,
2233
                    y: item.y * geometry.size.height
2234
                )
2235

            
2236
                if firstSample == nil {
2237
                    firstSample = point
2238
                }
2239
                lastSample = point
2240

            
2241
                if needsMove {
2242
                    path.move(to: renderedPoint)
2243
                    needsMove = false
2244
                } else {
2245
                    path.addLine(to: renderedPoint)
2246
                }
Bogdan Timofte authored 2 weeks ago
2247
            }
Bogdan Timofte authored a week ago
2248

            
2249
            if self.areaChart, let firstSample, let lastSample {
2250
                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
2251
                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
2252
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
2253
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
2254
                // MARK: Nu e nevoie. Fill inchide automat calea
2255
                // path.closeSubpath()
2256
            }
2257
        }
2258
    }
2259

            
2260
}