Showing 1 changed files with 302 additions and 121 deletions
+302 -121
USB Meter/Views/Meter/Measurements/Chart/MeasurementChartView.swift
@@ -10,9 +10,12 @@ import SwiftUI
10 10
 
11 11
 struct MeasurementChartView: View {
12 12
     private let minimumTimeSpan: TimeInterval = 1
13
-    private let minimumVoltageSpan = 0.1
14
-    private let minimumCurrentSpan = 0.1
15
-    private let minimumPowerSpan = 0.1
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
16 19
     
17 20
     @EnvironmentObject private var measurements: Measurements
18 21
     var timeRange: ClosedRange<Date>? = nil
@@ -34,91 +37,172 @@ struct MeasurementChartView: View {
34 37
         )
35 38
 
36 39
         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)
63
-                if let primarySeries {
64
-                    VStack {
65
-                        GeometryReader { geometry in
66
-                            HStack {
67
-                                Group { // MARK: Left Legend
68
-                                    if self.displayPower {
69
-                                        self.yAxisLabelsView(geometry: geometry, context: powerSeries.context, measurementUnit: "W")
70
-                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .red, opacity: 0.5)
71
-                                    } else if self.displayVoltage {
72
-                                        self.yAxisLabelsView(geometry: geometry, context: voltageSeries.context, measurementUnit: "V")
73
-                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .green, opacity: 0.5)
74
-                                    }
75
-                                    else if self.displayCurrent {
76
-                                        self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
77
-                                            .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
78
-                                    }
79
-                                }
80
-                                ZStack { // MARK: Graph
81
-                                    if self.displayPower {
82
-                                        Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
83
-                                            .opacity(0.5)
84
-                                    } else {
85
-                                        if self.displayVoltage{
86
-                                            Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
87
-                                                .opacity(0.5)
88
-                                        }
89
-                                        if self.displayCurrent{
90
-                                            Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
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
101
-                                    self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
102
-                                        .foregroundColor(self.displayVoltage && self.displayCurrent ? .primary : .clear)
103
-                                        .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
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
+                                    )
104 71
                                 }
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)
105 83
                             }
84
+
85
+                            xAxisLabelsView(context: primarySeries.context)
86
+                            .frame(height: xAxisHeight)
106 87
                         }
107
-                        xAxisLabelsView(context: primarySeries.context)
108
-                            .padding(.horizontal, 10)
109
-                        
88
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
110 89
                     }
111 90
                 }
112
-                else {
91
+            } else {
92
+                VStack(alignment: .leading, spacing: 12) {
93
+                    chartToggleBar
113 94
                     Text("Nothing to show!")
95
+                        .foregroundColor(.secondary)
114 96
                 }
115
-                
116 97
             }
117
-            .padding(10)
118
-            .font(.footnote)
119
-            .frame(maxWidth: .greatestFiniteMagnitude)
120
-            .withRoundedRectangleBackground( cornerRadius: 15, foregroundColor: .primary, opacity: 0.03 )
121
-            .padding()
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
+            )
122 206
         }
123 207
     }
124 208
 
@@ -159,77 +243,174 @@ struct MeasurementChartView: View {
159 243
         }
160 244
         return (points, context)
161 245
     }
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
+    }
162 266
     
163 267
     // 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 {
268
+    fileprivate func xAxisLabelsView(
269
+        context: ChartContext
270
+    ) -> some View {
165 271
         var timeFormat: String?
166 272
         switch context.size.width {
167 273
         case 0..<3600: timeFormat = "HH:mm:ss"
168 274
         case 3600...86400: timeFormat = "HH:mm"
169
-        default: timeFormat = "E:HH:MM"
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!)
170 279
         }
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()
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
+                            )
178 323
                     }
179 324
                 }
325
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
180 326
             }
327
+
328
+            Color.clear
329
+                .frame(width: axisColumnWidth)
181 330
         }
182 331
     }
183 332
     
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
-                    }
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
+                        )
197 358
                 }
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 {
359
+
203 360
                 Text(measurementUnit)
204
-                    .fontWeight(.bold)
205
-                    .padding(.top, 5)
206
-                Spacer()
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)
207 370
             }
208 371
         }
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
+        )
209 381
     }
210 382
     
211
-    fileprivate func horizontalGuides() -> some View {
383
+    fileprivate func horizontalGuides(context: ChartContext) -> some View {
212 384
         GeometryReader { geometry in
213 385
             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)))
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))
219 393
                 }
220
-            }.stroke(lineWidth: 0.25)
394
+            }
395
+            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
221 396
         }
222 397
     }
223 398
     
224
-    fileprivate func verticalGuides() -> some View {
399
+    fileprivate func verticalGuides(context: ChartContext) -> some View {
225 400
         GeometryReader { geometry in
226 401
             Path { path in
227 402
                 
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) )
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) )
230 410
                     path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
231 411
                 }
232
-            }.stroke(lineWidth: 0.25)
412
+            }
413
+            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
233 414
         }
234 415
     }
235 416