|
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
|
}
|