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

            
9
import SwiftUI
10

            
11
struct MeasurementChartView: View {
Bogdan Timofte authored 2 weeks ago
12
    private let minimumTimeSpan: TimeInterval = 1
Bogdan Timofte authored 2 weeks ago
13
    private let minimumVoltageSpan = 0.5
14
    private let minimumCurrentSpan = 0.5
15
    private let minimumPowerSpan = 0.5
16
    private let axisColumnWidth: CGFloat = 46
17
    private let chartSectionSpacing: CGFloat = 8
18
    private let xAxisHeight: CGFloat = 28
Bogdan Timofte authored 2 weeks ago
19

            
20
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 weeks ago
21
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 weeks ago
22

            
23
    @State var displayVoltage: Bool = false
24
    @State var displayCurrent: Bool = false
25
    @State var displayPower: Bool = true
26
    let xLabels: Int = 4
27
    let yLabels: Int = 4
28

            
29
    var body: some View {
Bogdan Timofte authored 2 weeks ago
30
        let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan)
31
        let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan)
32
        let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan)
33
        let primarySeries = displayedPrimarySeries(
34
            powerSeries: powerSeries,
35
            voltageSeries: voltageSeries,
36
            currentSeries: currentSeries
37
        )
38

            
Bogdan Timofte authored 2 weeks ago
39
        Group {
Bogdan Timofte authored 2 weeks ago
40
            if let primarySeries {
41
                VStack(alignment: .leading, spacing: 12) {
42
                    chartToggleBar
43

            
44
                    GeometryReader { geometry in
45
                        let plotHeight = max(geometry.size.height - xAxisHeight, 140)
46

            
47
                        VStack(spacing: 6) {
48
                            HStack(spacing: chartSectionSpacing) {
49
                                primaryAxisView(
50
                                    height: plotHeight,
51
                                    powerSeries: powerSeries,
52
                                    voltageSeries: voltageSeries,
53
                                    currentSeries: currentSeries
54
                                )
55
                                .frame(width: axisColumnWidth, height: plotHeight)
56

            
57
                                ZStack {
58
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
59
                                        .fill(Color.primary.opacity(0.05))
60

            
61
                                    RoundedRectangle(cornerRadius: 18, style: .continuous)
62
                                        .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
63

            
64
                                    horizontalGuides(context: primarySeries.context)
65
                                    verticalGuides(context: primarySeries.context)
66
                                    renderedChart(
67
                                        powerSeries: powerSeries,
68
                                        voltageSeries: voltageSeries,
69
                                        currentSeries: currentSeries
70
                                    )
Bogdan Timofte authored 2 weeks ago
71
                                }
Bogdan Timofte authored 2 weeks ago
72
                                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
73
                                .frame(maxWidth: .infinity)
74
                                .frame(height: plotHeight)
75

            
76
                                secondaryAxisView(
77
                                    height: plotHeight,
78
                                    powerSeries: powerSeries,
79
                                    voltageSeries: voltageSeries,
80
                                    currentSeries: currentSeries
81
                                )
82
                                .frame(width: axisColumnWidth, height: plotHeight)
Bogdan Timofte authored 2 weeks ago
83
                            }
Bogdan Timofte authored 2 weeks ago
84

            
85
                            xAxisLabelsView(context: primarySeries.context)
86
                            .frame(height: xAxisHeight)
Bogdan Timofte authored 2 weeks ago
87
                        }
Bogdan Timofte authored 2 weeks ago
88
                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
Bogdan Timofte authored 2 weeks ago
89
                    }
90
                }
Bogdan Timofte authored 2 weeks ago
91
            } else {
92
                VStack(alignment: .leading, spacing: 12) {
93
                    chartToggleBar
Bogdan Timofte authored 2 weeks ago
94
                    Text("Nothing to show!")
Bogdan Timofte authored 2 weeks ago
95
                        .foregroundColor(.secondary)
Bogdan Timofte authored 2 weeks ago
96
                }
97
            }
Bogdan Timofte authored 2 weeks ago
98
        }
99
        .font(.footnote)
100
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
101
    }
102

            
103
    private var chartToggleBar: some View {
104
        HStack(spacing: 8) {
105
            Button(action: {
106
                self.displayVoltage.toggle()
107
                if self.displayVoltage {
108
                    self.displayPower = false
109
                }
110
            }) { Text("Voltage") }
111
            .asEnableFeatureButton(state: displayVoltage)
112

            
113
            Button(action: {
114
                self.displayCurrent.toggle()
115
                if self.displayCurrent {
116
                    self.displayPower = false
117
                }
118
            }) { Text("Current") }
119
            .asEnableFeatureButton(state: displayCurrent)
120

            
121
            Button(action: {
122
                self.displayPower.toggle()
123
                if self.displayPower {
124
                    self.displayCurrent = false
125
                    self.displayVoltage = false
126
                }
127
            }) { Text("Power") }
128
            .asEnableFeatureButton(state: displayPower)
129
        }
130
        .frame(maxWidth: .infinity, alignment: .center)
131
    }
132

            
133
    @ViewBuilder
134
    private func primaryAxisView(
135
        height: CGFloat,
136
        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
137
        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
138
        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
139
    ) -> some View {
140
        if displayPower {
141
            yAxisLabelsView(
142
                height: height,
143
                context: powerSeries.context,
144
                measurementUnit: "W",
145
                tint: .red
146
            )
147
        } else if displayVoltage {
148
            yAxisLabelsView(
149
                height: height,
150
                context: voltageSeries.context,
151
                measurementUnit: "V",
152
                tint: .green
153
            )
154
        } else if displayCurrent {
155
            yAxisLabelsView(
156
                height: height,
157
                context: currentSeries.context,
158
                measurementUnit: "A",
159
                tint: .blue
160
            )
161
        }
162
    }
163

            
164
    @ViewBuilder
165
    private func renderedChart(
166
        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
167
        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
168
        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
169
    ) -> some View {
170
        if self.displayPower {
171
            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
172
                .opacity(0.72)
173
        } else {
174
            if self.displayVoltage {
175
                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
176
                    .opacity(0.78)
177
            }
178
            if self.displayCurrent {
179
                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
180
                    .opacity(0.78)
181
            }
182
        }
183
    }
184

            
185
    @ViewBuilder
186
    private func secondaryAxisView(
187
        height: CGFloat,
188
        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
189
        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
190
        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
191
    ) -> some View {
192
        if displayVoltage && displayCurrent {
193
            yAxisLabelsView(
194
                height: height,
195
                context: currentSeries.context,
196
                measurementUnit: "A",
197
                tint: .blue
198
            )
199
        } else {
200
            primaryAxisView(
201
                height: height,
202
                powerSeries: powerSeries,
203
                voltageSeries: voltageSeries,
204
                currentSeries: currentSeries
205
            )
Bogdan Timofte authored 2 weeks ago
206
        }
207
    }
Bogdan Timofte authored 2 weeks ago
208

            
209
    private func displayedPrimarySeries(
210
        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
211
        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
212
        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
213
    ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
214
        if displayPower {
215
            return powerSeries.points.isEmpty ? nil : powerSeries
216
        }
217
        if displayVoltage {
218
            return voltageSeries.points.isEmpty ? nil : voltageSeries
219
        }
220
        if displayCurrent {
221
            return currentSeries.points.isEmpty ? nil : currentSeries
222
        }
223
        return nil
224
    }
225

            
226
    private func series(
227
        for measurement: Measurements.Measurement,
228
        minimumYSpan: Double
229
    ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
230
        let points = measurement.points.filter { point in
231
            guard let timeRange else { return true }
232
            return timeRange.contains(point.timestamp)
233
        }
234
        let context = ChartContext()
235
        for point in points {
236
            context.include(point: point.point())
237
        }
238
        if !points.isEmpty {
239
            context.ensureMinimumSize(
240
                width: CGFloat(minimumTimeSpan),
241
                height: CGFloat(minimumYSpan)
242
            )
243
        }
244
        return (points, context)
245
    }
Bogdan Timofte authored 2 weeks ago
246

            
247
    private func yGuidePosition(
248
        for labelIndex: Int,
249
        context: ChartContext,
250
        height: CGFloat
251
    ) -> CGFloat {
252
        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
253
        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
254
        return context.placeInRect(point: anchorPoint).y * height
255
    }
256

            
257
    private func xGuidePosition(
258
        for labelIndex: Int,
259
        context: ChartContext,
260
        width: CGFloat
261
    ) -> CGFloat {
262
        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
263
        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
264
        return context.placeInRect(point: anchorPoint).x * width
265
    }
Bogdan Timofte authored 2 weeks ago
266

            
267
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
Bogdan Timofte authored 2 weeks ago
268
    fileprivate func xAxisLabelsView(
269
        context: ChartContext
270
    ) -> some View {
Bogdan Timofte authored 2 weeks ago
271
        var timeFormat: String?
272
        switch context.size.width {
273
        case 0..<3600: timeFormat = "HH:mm:ss"
274
        case 3600...86400: timeFormat = "HH:mm"
Bogdan Timofte authored 2 weeks ago
275
        default: timeFormat = "E HH:mm"
276
        }
277
        let labels = (1...xLabels).map {
278
            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: self.xLabels)).format(as: timeFormat!)
Bogdan Timofte authored 2 weeks ago
279
        }
Bogdan Timofte authored 2 weeks ago
280

            
281
        return HStack(spacing: chartSectionSpacing) {
282
            Color.clear
283
                .frame(width: axisColumnWidth)
284

            
285
            GeometryReader { geometry in
286
                let labelWidth = max(
287
                    geometry.size.width / CGFloat(max(xLabels - 1, 1)),
288
                    1
289
                )
290

            
291
                ZStack(alignment: .topLeading) {
292
                    Path { path in
293
                        for labelIndex in 1...self.xLabels {
294
                            let x = xGuidePosition(
295
                                for: labelIndex,
296
                                context: context,
297
                                width: geometry.size.width
298
                            )
299
                            path.move(to: CGPoint(x: x, y: 0))
300
                            path.addLine(to: CGPoint(x: x, y: 6))
301
                        }
302
                    }
303
                    .stroke(Color.secondary.opacity(0.26), lineWidth: 0.75)
304

            
305
                    ForEach(Array(labels.enumerated()), id: \.offset) { item in
306
                        let labelIndex = item.offset + 1
307
                        let centerX = xGuidePosition(
308
                            for: labelIndex,
309
                            context: context,
310
                            width: geometry.size.width
311
                        )
312

            
313
                        Text(item.element)
314
                            .font(.caption.weight(.semibold))
315
                            .monospacedDigit()
316
                            .lineLimit(1)
317
                            .minimumScaleFactor(0.68)
318
                            .frame(width: labelWidth)
319
                            .position(
320
                                x: centerX,
321
                                y: geometry.size.height * 0.7
322
                            )
Bogdan Timofte authored 2 weeks ago
323
                    }
324
                }
Bogdan Timofte authored 2 weeks ago
325
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Bogdan Timofte authored 2 weeks ago
326
            }
Bogdan Timofte authored 2 weeks ago
327

            
328
            Color.clear
329
                .frame(width: axisColumnWidth)
Bogdan Timofte authored 2 weeks ago
330
        }
331
    }
332

            
Bogdan Timofte authored 2 weeks ago
333
    fileprivate func yAxisLabelsView(
334
        height: CGFloat,
335
        context: ChartContext,
336
        measurementUnit: String,
337
        tint: Color
338
    ) -> some View {
339
        GeometryReader { geometry in
340
            ZStack(alignment: .top) {
341
                ForEach(0..<yLabels, id: \.self) { row in
342
                    let labelIndex = yLabels - row
343

            
344
                    Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(fractionDigits: 1))")
345
                        .font(.caption2.weight(.semibold))
346
                        .monospacedDigit()
347
                        .lineLimit(1)
348
                        .minimumScaleFactor(0.72)
349
                        .frame(width: max(geometry.size.width - 6, 0))
350
                        .position(
351
                            x: geometry.size.width / 2,
352
                            y: yGuidePosition(
353
                                for: labelIndex,
354
                                context: context,
355
                                height: geometry.size.height
356
                            )
357
                        )
Bogdan Timofte authored 2 weeks ago
358
                }
Bogdan Timofte authored 2 weeks ago
359

            
Bogdan Timofte authored 2 weeks ago
360
                Text(measurementUnit)
Bogdan Timofte authored 2 weeks ago
361
                    .font(.caption2.weight(.bold))
362
                    .foregroundColor(tint)
363
                    .padding(.horizontal, 6)
364
                    .padding(.vertical, 4)
365
                    .background(
366
                        Capsule(style: .continuous)
367
                            .fill(tint.opacity(0.14))
368
                    )
369
                    .padding(.top, 6)
Bogdan Timofte authored 2 weeks ago
370
            }
371
        }
Bogdan Timofte authored 2 weeks ago
372
        .frame(height: height)
373
        .background(
374
            RoundedRectangle(cornerRadius: 16, style: .continuous)
375
                .fill(tint.opacity(0.12))
376
        )
377
        .overlay(
378
            RoundedRectangle(cornerRadius: 16, style: .continuous)
379
                .stroke(tint.opacity(0.20), lineWidth: 1)
380
        )
Bogdan Timofte authored 2 weeks ago
381
    }
382

            
Bogdan Timofte authored 2 weeks ago
383
    fileprivate func horizontalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
384
        GeometryReader { geometry in
385
            Path { path in
Bogdan Timofte authored 2 weeks ago
386
                for labelIndex in 1...self.yLabels {
387
                    let y = yGuidePosition(
388
                        for: labelIndex,
389
                        context: context,
390
                        height: geometry.size.height
391
                    )
392
                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
Bogdan Timofte authored 2 weeks ago
393
                }
Bogdan Timofte authored 2 weeks ago
394
            }
395
            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
Bogdan Timofte authored 2 weeks ago
396
        }
397
    }
398

            
Bogdan Timofte authored 2 weeks ago
399
    fileprivate func verticalGuides(context: ChartContext) -> some View {
Bogdan Timofte authored 2 weeks ago
400
        GeometryReader { geometry in
401
            Path { path in
402

            
Bogdan Timofte authored 2 weeks ago
403
                for labelIndex in 2..<self.xLabels {
404
                    let x = xGuidePosition(
405
                        for: labelIndex,
406
                        context: context,
407
                        width: geometry.size.width
408
                    )
409
                    path.move(to: CGPoint(x: x, y: 0) )
Bogdan Timofte authored 2 weeks ago
410
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
411
                }
Bogdan Timofte authored 2 weeks ago
412
            }
413
            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
Bogdan Timofte authored 2 weeks ago
414
        }
415
    }
416

            
417
}
418

            
419
struct Chart : View {
420

            
Bogdan Timofte authored 2 weeks ago
421
    let points: [Measurements.Measurement.Point]
422
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
423
    var areaChart: Bool = false
424
    var strokeColor: Color = .black
425

            
426
    var body : some View {
427
        GeometryReader { geometry in
428
            if self.areaChart {
429
                self.path( geometry: geometry )
430
                    .fill(LinearGradient( gradient: .init(colors: [Color.red, Color.green]), startPoint: .init(x: 0.5, y: 0.1), endPoint: .init(x: 0.5, y: 0.9)))
431
            } else {
432
                self.path( geometry: geometry )
433
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
434
            }
435
        }
436
    }
437

            
438
    fileprivate func path(geometry: GeometryProxy) -> Path {
439
        return Path { path in
Bogdan Timofte authored 2 weeks ago
440
            guard let first = points.first else { return }
441
            let firstPoint = context.placeInRect(point: first.point())
Bogdan Timofte authored 2 weeks ago
442
            path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) )
Bogdan Timofte authored 2 weeks ago
443
            for item in points.map({ context.placeInRect(point: $0.point()) }) {
Bogdan Timofte authored 2 weeks ago
444
                path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) )
445
            }
446
            if self.areaChart {
Bogdan Timofte authored 2 weeks ago
447
                let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y ))
448
                let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
449
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
450
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
451
                // MARK: Nu e nevoie. Fill inchide automat calea
452
                // path.closeSubpath()
453
            }
454
        }
455
    }
456

            
457
}