Newer Older
276 lines | 12.611kb
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
13
    private let minimumVoltageSpan = 0.1
14
    private let minimumCurrentSpan = 0.1
15
    private let minimumPowerSpan = 0.1
Bogdan Timofte authored 2 weeks ago
16

            
17
    @EnvironmentObject private var measurements: Measurements
Bogdan Timofte authored 2 weeks ago
18
    var timeRange: ClosedRange<Date>? = nil
Bogdan Timofte authored 2 weeks ago
19

            
20
    @State var displayVoltage: Bool = false
21
    @State var displayCurrent: Bool = false
22
    @State var displayPower: Bool = true
23
    let xLabels: Int = 4
24
    let yLabels: Int = 4
25

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

            
Bogdan Timofte authored 2 weeks ago
36
        Group {
37
            VStack {
38
                HStack {
39
                    Button( action: {
40
                        self.displayVoltage.toggle()
41
                        if self.displayVoltage {
42
                            self.displayPower = false
43
                        }
44
                    } ) { Text("Voltage") }
45
                        .asEnableFeatureButton(state: displayVoltage)
46
                    Button( action: {
47
                        self.displayCurrent.toggle()
48
                        if self.displayCurrent {
49
                            self.displayPower = false
50
                        }
51
                    } ) { Text("Current") }
52
                        .asEnableFeatureButton(state: displayCurrent)
53
                    Button( action: {
54
                        self.displayPower.toggle()
55
                        if self.displayPower {
56
                            self.displayCurrent = false
57
                            self.displayVoltage = false
58
                        }
59
                    } ) { Text("Power") }
60
                        .asEnableFeatureButton(state: displayPower)
61
                }
62
                .padding(.bottom, 5)
Bogdan Timofte authored 2 weeks ago
63
                if let primarySeries {
Bogdan Timofte authored 2 weeks ago
64
                    VStack {
65
                        GeometryReader { geometry in
66
                            HStack {
67
                                Group { // MARK: Left Legend
68
                                    if self.displayPower {
Bogdan Timofte authored 2 weeks ago
69
                                        self.yAxisLabelsView(geometry: geometry, context: powerSeries.context, measurementUnit: "W")
Bogdan Timofte authored 2 weeks ago
70
                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .red, opacity: 0.5)
71
                                    } else if self.displayVoltage {
Bogdan Timofte authored 2 weeks ago
72
                                        self.yAxisLabelsView(geometry: geometry, context: voltageSeries.context, measurementUnit: "V")
Bogdan Timofte authored 2 weeks ago
73
                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .green, opacity: 0.5)
74
                                    }
75
                                    else if self.displayCurrent {
Bogdan Timofte authored 2 weeks ago
76
                                        self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
Bogdan Timofte authored 2 weeks ago
77
                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
78
                                    }
79
                                }
80
                                ZStack { // MARK: Graph
81
                                    if self.displayPower {
Bogdan Timofte authored 2 weeks ago
82
                                        Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
Bogdan Timofte authored 2 weeks ago
83
                                            .opacity(0.5)
84
                                    } else {
85
                                        if self.displayVoltage{
Bogdan Timofte authored 2 weeks ago
86
                                            Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
Bogdan Timofte authored 2 weeks ago
87
                                                .opacity(0.5)
88
                                        }
89
                                        if self.displayCurrent{
Bogdan Timofte authored 2 weeks ago
90
                                            Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
Bogdan Timofte authored 2 weeks ago
91
                                                .opacity(0.5)
92
                                        }
93
                                    }
94

            
95
                                    // MARK: Grid
96
                                    self.horizontalGuides()
97
                                    self.verticalGuides()
98
                                }
99
                                .withRoundedRectangleBackground( cornerRadius: 0, foregroundColor: .primary, opacity: 0.06 )
100
                                Group { // MARK: Right Legend
Bogdan Timofte authored 2 weeks ago
101
                                    self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
Bogdan Timofte authored 2 weeks ago
102
                                        .foregroundColor(self.displayVoltage && self.displayCurrent ? .primary : .clear)
103
                                        .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
104
                                }
105
                            }
106
                        }
Bogdan Timofte authored 2 weeks ago
107
                        xAxisLabelsView(context: primarySeries.context)
Bogdan Timofte authored 2 weeks ago
108
                            .padding(.horizontal, 10)
109

            
110
                    }
111
                }
112
                else {
113
                    Text("Nothing to show!")
114
                }
115

            
116
            }
117
            .padding(10)
118
            .font(.footnote)
119
            .frame(maxWidth: .greatestFiniteMagnitude)
120
            .withRoundedRectangleBackground( cornerRadius: 15, foregroundColor: .primary, opacity: 0.03 )
121
            .padding()
122
        }
123
    }
Bogdan Timofte authored 2 weeks ago
124

            
125
    private func displayedPrimarySeries(
126
        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
127
        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
128
        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
129
    ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
130
        if displayPower {
131
            return powerSeries.points.isEmpty ? nil : powerSeries
132
        }
133
        if displayVoltage {
134
            return voltageSeries.points.isEmpty ? nil : voltageSeries
135
        }
136
        if displayCurrent {
137
            return currentSeries.points.isEmpty ? nil : currentSeries
138
        }
139
        return nil
140
    }
141

            
142
    private func series(
143
        for measurement: Measurements.Measurement,
144
        minimumYSpan: Double
145
    ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
146
        let points = measurement.points.filter { point in
147
            guard let timeRange else { return true }
148
            return timeRange.contains(point.timestamp)
149
        }
150
        let context = ChartContext()
151
        for point in points {
152
            context.include(point: point.point())
153
        }
154
        if !points.isEmpty {
155
            context.ensureMinimumSize(
156
                width: CGFloat(minimumTimeSpan),
157
                height: CGFloat(minimumYSpan)
158
            )
159
        }
160
        return (points, context)
161
    }
Bogdan Timofte authored 2 weeks ago
162

            
163
    // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
164
    fileprivate func xAxisLabelsView(context: ChartContext) -> some View {
165
        var timeFormat: String?
166
        switch context.size.width {
167
        case 0..<3600: timeFormat = "HH:mm:ss"
168
        case 3600...86400: timeFormat = "HH:mm"
169
        default: timeFormat = "E:HH:MM"
170
        }
171
        return HStack {
172
            ForEach (1...xLabels, id: \.self) { i in
173
                Group {
174
                    Text( "\(Date(timeIntervalSince1970: context.xAxisLabel(for: i, of: self.yLabels)).format(as: timeFormat!))" )
175
                        .fontWeight(.semibold)
176
                    if i < self.xLabels {
177
                        Spacer()
178
                    }
179
                }
180
            }
181
        }
182
    }
183

            
184
    fileprivate func yAxisLabelsView(geometry: GeometryProxy, context: ChartContext, measurementUnit: String) -> some View {
185
        return ZStack {
186
            VStack {
187
                Text("\(context.yAxisLabel(for: 4, of: 4).format(fractionDigits: 2))")
188
                    .fontWeight(.semibold)
189
                    .padding(.top, geometry.size.height*Constants.chartUnderscan/2 )
190
                Spacer()
191
                ForEach (1..<yLabels-1, id: \.self) { i in
192
                    Group {
193
                        Text("\(context.yAxisLabel(for: self.yLabels-i, of: self.yLabels).format(fractionDigits: 2))")
194
                            .fontWeight(.semibold)
195
                        Spacer()
196
                    }
197
                }
198
                Text("\(context.yAxisLabel(for: 1, of: yLabels).format(fractionDigits: 2))")
199
                    .fontWeight(.semibold)
200
                    .padding(.bottom, geometry.size.height*Constants.chartUnderscan/2 )
201
            }
202
            VStack {
203
                Text(measurementUnit)
204
                    .fontWeight(.bold)
205
                    .padding(.top, 5)
206
                Spacer()
207
            }
208
        }
209
    }
210

            
211
    fileprivate func horizontalGuides() -> some View {
212
        GeometryReader { geometry in
213
            Path { path in
214
                let pading = geometry.size.height*Constants.chartUnderscan
215
                let height = geometry.size.height - pading
216
                let border = pading/2
217
                for i: CGFloat in stride(from: 0, through: CGFloat(self.yLabels-1), by: 1) {
218
                    path.addLine(from: CGPoint(x: 0, y: border + height*i/CGFloat(self.yLabels-1 )), to: CGPoint(x: geometry.size.width, y: border + height*i/CGFloat(self.yLabels-1)))
219
                }
220
            }.stroke(lineWidth: 0.25)
221
        }
222
    }
223

            
224
    fileprivate func verticalGuides() -> some View {
225
        GeometryReader { geometry in
226
            Path { path in
227

            
228
                for i: CGFloat in stride(from: 1, through: CGFloat(self.xLabels-1), by: 1) {
229
                    path.move(to: CGPoint(x: geometry.size.width*i/CGFloat(self.xLabels-1), y: 0) )
230
                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
231
                }
232
            }.stroke(lineWidth: 0.25)
233
        }
234
    }
235

            
236
}
237

            
238
struct Chart : View {
239

            
Bogdan Timofte authored 2 weeks ago
240
    let points: [Measurements.Measurement.Point]
241
    let context: ChartContext
Bogdan Timofte authored 2 weeks ago
242
    var areaChart: Bool = false
243
    var strokeColor: Color = .black
244

            
245
    var body : some View {
246
        GeometryReader { geometry in
247
            if self.areaChart {
248
                self.path( geometry: geometry )
249
                    .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)))
250
            } else {
251
                self.path( geometry: geometry )
252
                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
253
            }
254
        }
255
    }
256

            
257
    fileprivate func path(geometry: GeometryProxy) -> Path {
258
        return Path { path in
Bogdan Timofte authored 2 weeks ago
259
            guard let first = points.first else { return }
260
            let firstPoint = context.placeInRect(point: first.point())
Bogdan Timofte authored 2 weeks ago
261
            path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) )
Bogdan Timofte authored 2 weeks ago
262
            for item in points.map({ context.placeInRect(point: $0.point()) }) {
Bogdan Timofte authored 2 weeks ago
263
                path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) )
264
            }
265
            if self.areaChart {
Bogdan Timofte authored 2 weeks ago
266
                let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y ))
267
                let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y ))
Bogdan Timofte authored 2 weeks ago
268
                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
269
                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
270
                // MARK: Nu e nevoie. Fill inchide automat calea
271
                // path.closeSubpath()
272
            }
273
        }
274
    }
275

            
276
}