Showing 9 changed files with 597 additions and 364 deletions
+12 -0
USB Meter.xcodeproj/project.pbxproj
@@ -25,6 +25,7 @@
25 25
 		437D47D52415FD8C00B7768E /* ChargeRecordSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */; };
26 26
 		437D47D72415FDF300B7768E /* MeterScreenControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */; };
27 27
 		437F0AB72463108F005DEBEC /* MeasurementChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F0AB62463108F005DEBEC /* MeasurementChartView.swift */; };
28
+		437F0AB92463108F005DEBEC /* TimeSeriesChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F0AB82463108F005DEBEC /* TimeSeriesChart.swift */; };
28 29
 		4383B460240EB2D000DAAEBF /* Meter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B45F240EB2D000DAAEBF /* Meter.swift */; };
29 30
 		4383B462240EB5E400DAAEBF /* AppData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B461240EB5E400DAAEBF /* AppData.swift */; };
30 31
 		4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4383B464240EB6B200DAAEBF /* UserDefault.swift */; };
@@ -137,6 +138,7 @@
137 138
 		437D47D42415FD8C00B7768E /* ChargeRecordSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeRecordSheetView.swift; sourceTree = "<group>"; };
138 139
 		437D47D62415FDF300B7768E /* MeterScreenControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterScreenControlsView.swift; sourceTree = "<group>"; };
139 140
 		437F0AB62463108F005DEBEC /* MeasurementChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeasurementChartView.swift; sourceTree = "<group>"; };
141
+		437F0AB82463108F005DEBEC /* TimeSeriesChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSeriesChart.swift; sourceTree = "<group>"; };
140 142
 		4383B45F240EB2D000DAAEBF /* Meter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Meter.swift; sourceTree = "<group>"; };
141 143
 		4383B461240EB5E400DAAEBF /* AppData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppData.swift; sourceTree = "<group>"; };
142 144
 		4383B464240EB6B200DAAEBF /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
@@ -522,11 +524,20 @@
522 524
 		D28F10023C8E4A7A00A10002 /* Components */ = {
523 525
 			isa = PBXGroup;
524 526
 			children = (
527
+				437F0AB32463108F005DEBEC /* Charts */,
525 528
 				D28F10033C8E4A7A00A10003 /* Generic */,
526 529
 			);
527 530
 			path = Components;
528 531
 			sourceTree = "<group>";
529 532
 		};
533
+		437F0AB32463108F005DEBEC /* Charts */ = {
534
+			isa = PBXGroup;
535
+			children = (
536
+				437F0AB82463108F005DEBEC /* TimeSeriesChart.swift */,
537
+			);
538
+			path = Charts;
539
+			sourceTree = "<group>";
540
+		};
530 541
 		D28F10033C8E4A7A00A10003 /* Generic */ = {
531 542
 			isa = PBXGroup;
532 543
 			children = (
@@ -811,6 +822,7 @@
811 822
 				AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */,
812 823
 				43CBF667240BF3EB00255B8B /* ContentView.swift in Sources */,
813 824
 				437F0AB72463108F005DEBEC /* MeasurementChartView.swift in Sources */,
825
+				437F0AB92463108F005DEBEC /* TimeSeriesChart.swift in Sources */,
814 826
 				437D47D32415FB7E00B7768E /* Decimal.swift in Sources */,
815 827
 				43874C7F2414F3F400525397 /* Float.swift in Sources */,
816 828
 				4383B462240EB5E400DAAEBF /* AppData.swift in Sources */,
+23 -0
USB Meter/Model/Measurements.swift
@@ -754,3 +754,26 @@ class Measurements : ObservableObject {
754 754
         )
755 755
     }
756 756
 }
757
+
758
+extension Measurements.Measurement.Point: TimeSeriesChartPointRepresentable {
759
+    var chartPointID: Int {
760
+        id
761
+    }
762
+
763
+    var chartTimestamp: Date {
764
+        timestamp
765
+    }
766
+
767
+    var chartValue: Double {
768
+        value
769
+    }
770
+
771
+    var chartPointKind: TimeSeriesChartPointKind {
772
+        switch kind {
773
+        case .sample:
774
+            return .sample
775
+        case .discontinuity:
776
+            return .discontinuity
777
+        }
778
+    }
779
+}
+1 -1
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -1020,7 +1020,7 @@ struct ChargedDeviceDetailView: View {
1020 1020
                 .foregroundColor(.secondary)
1021 1021
             }
1022 1022
 
1023
-            Chart(
1023
+            TimeSeriesChart(
1024 1024
                 points: snapshot.points,
1025 1025
                 context: snapshot.context,
1026 1026
                 areaChart: areaChart,
+422 -0
USB Meter/Views/Components/Charts/TimeSeriesChart.swift
@@ -0,0 +1,422 @@
1
+//
2
+//  TimeSeriesChart.swift
3
+//  USB Meter
4
+//
5
+
6
+import CoreGraphics
7
+import Foundation
8
+import SwiftUI
9
+
10
+enum TimeSeriesChartPointKind: Hashable {
11
+    case sample
12
+    case discontinuity
13
+}
14
+
15
+protocol TimeSeriesChartPointRepresentable {
16
+    var chartPointID: Int { get }
17
+    var chartTimestamp: Date { get }
18
+    var chartValue: Double { get }
19
+    var chartPointKind: TimeSeriesChartPointKind { get }
20
+}
21
+
22
+extension TimeSeriesChartPointRepresentable {
23
+    var isChartSample: Bool {
24
+        chartPointKind == .sample
25
+    }
26
+
27
+    var isChartDiscontinuity: Bool {
28
+        chartPointKind == .discontinuity
29
+    }
30
+
31
+    func chartCGPoint() -> CGPoint {
32
+        CGPoint(x: chartTimestamp.timeIntervalSince1970, y: chartValue)
33
+    }
34
+}
35
+
36
+struct TimeSeriesChartStyle {
37
+    var drawsArea: Bool
38
+    var strokeColor: Color
39
+    var areaFillColor: Color?
40
+    var lineWidth: CGFloat
41
+
42
+    static func line(
43
+        strokeColor: Color = .black,
44
+        lineWidth: CGFloat = 2
45
+    ) -> TimeSeriesChartStyle {
46
+        TimeSeriesChartStyle(
47
+            drawsArea: false,
48
+            strokeColor: strokeColor,
49
+            areaFillColor: nil,
50
+            lineWidth: lineWidth
51
+        )
52
+    }
53
+
54
+    static func area(
55
+        strokeColor: Color = .black,
56
+        areaFillColor: Color? = nil,
57
+        lineWidth: CGFloat = 2
58
+    ) -> TimeSeriesChartStyle {
59
+        TimeSeriesChartStyle(
60
+            drawsArea: true,
61
+            strokeColor: strokeColor,
62
+            areaFillColor: areaFillColor,
63
+            lineWidth: lineWidth
64
+        )
65
+    }
66
+}
67
+
68
+struct TimeSeriesChart<Point: TimeSeriesChartPointRepresentable>: View {
69
+    @Environment(\.displayScale) private var displayScale
70
+
71
+    let points: [Point]
72
+    let context: ChartContext
73
+    let style: TimeSeriesChartStyle
74
+
75
+    init(
76
+        points: [Point],
77
+        context: ChartContext,
78
+        style: TimeSeriesChartStyle
79
+    ) {
80
+        self.points = points
81
+        self.context = context
82
+        self.style = style
83
+    }
84
+
85
+    init(
86
+        points: [Point],
87
+        context: ChartContext,
88
+        areaChart: Bool = false,
89
+        strokeColor: Color = .black,
90
+        areaFillColor: Color? = nil
91
+    ) {
92
+        self.points = points
93
+        self.context = context
94
+        self.style = areaChart
95
+            ? .area(strokeColor: strokeColor, areaFillColor: areaFillColor)
96
+            : .line(strokeColor: strokeColor)
97
+    }
98
+
99
+    var body: some View {
100
+        GeometryReader { geometry in
101
+            if style.drawsArea {
102
+                let fillColor = style.areaFillColor ?? style.strokeColor.opacity(0.2)
103
+                path(geometry: geometry)
104
+                    .fill(
105
+                        LinearGradient(
106
+                            gradient: .init(
107
+                                colors: [
108
+                                    fillColor.opacity(0.72),
109
+                                    fillColor.opacity(0.18)
110
+                                ]
111
+                            ),
112
+                            startPoint: .init(x: 0.5, y: 0.08),
113
+                            endPoint: .init(x: 0.5, y: 0.92)
114
+                        )
115
+                    )
116
+            } else {
117
+                path(geometry: geometry)
118
+                    .stroke(
119
+                        style.strokeColor,
120
+                        style: StrokeStyle(
121
+                            lineWidth: style.lineWidth,
122
+                            lineCap: .round,
123
+                            lineJoin: .round
124
+                        )
125
+                    )
126
+            }
127
+        }
128
+    }
129
+
130
+    private func path(geometry: GeometryProxy) -> Path {
131
+        let displayedPoints = scaledPoints(for: geometry.size.width)
132
+        let baselineY = context.placeInRect(
133
+            point: CGPoint(x: context.origin.x, y: context.origin.y)
134
+        ).y * geometry.size.height
135
+
136
+        return Path { path in
137
+            var firstRenderedPoint: CGPoint?
138
+            var lastRenderedPoint: CGPoint?
139
+            var needsMove = true
140
+
141
+            for point in displayedPoints {
142
+                if point.isDiscontinuity {
143
+                    closeAreaSegment(
144
+                        in: &path,
145
+                        firstPoint: firstRenderedPoint,
146
+                        lastPoint: lastRenderedPoint,
147
+                        baselineY: baselineY
148
+                    )
149
+                    firstRenderedPoint = nil
150
+                    lastRenderedPoint = nil
151
+                    needsMove = true
152
+                    continue
153
+                }
154
+
155
+                let item = context.placeInRect(point: point.cgPoint)
156
+                let renderedPoint = CGPoint(
157
+                    x: item.x * geometry.size.width,
158
+                    y: item.y * geometry.size.height
159
+                )
160
+
161
+                if needsMove {
162
+                    path.move(to: renderedPoint)
163
+                    firstRenderedPoint = renderedPoint
164
+                    needsMove = false
165
+                } else {
166
+                    path.addLine(to: renderedPoint)
167
+                }
168
+
169
+                lastRenderedPoint = renderedPoint
170
+            }
171
+
172
+            closeAreaSegment(
173
+                in: &path,
174
+                firstPoint: firstRenderedPoint,
175
+                lastPoint: lastRenderedPoint,
176
+                baselineY: baselineY
177
+            )
178
+        }
179
+    }
180
+
181
+    private func closeAreaSegment(
182
+        in path: inout Path,
183
+        firstPoint: CGPoint?,
184
+        lastPoint: CGPoint?,
185
+        baselineY: CGFloat
186
+    ) {
187
+        guard style.drawsArea, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
188
+
189
+        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
190
+        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
191
+        path.closeSubpath()
192
+    }
193
+
194
+    private func scaledPoints(for width: CGFloat) -> [TimeSeriesChartRenderPoint] {
195
+        let renderPoints = points.map(TimeSeriesChartRenderPoint.init)
196
+        let sampleCount = renderPoints.reduce(into: 0) { partialResult, point in
197
+            if point.isSample {
198
+                partialResult += 1
199
+            }
200
+        }
201
+
202
+        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
203
+        let maximumSamplesToRender = max(displayColumns * (style.drawsArea ? 3 : 4), 240)
204
+
205
+        guard sampleCount > maximumSamplesToRender, context.isValid else {
206
+            return renderPoints
207
+        }
208
+
209
+        var scaledPoints: [TimeSeriesChartRenderPoint] = []
210
+        var currentSegment: [TimeSeriesChartRenderPoint] = []
211
+
212
+        for point in renderPoints {
213
+            if point.isDiscontinuity {
214
+                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
215
+                currentSegment.removeAll(keepingCapacity: true)
216
+
217
+                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
218
+                    appendScaledPoint(point, to: &scaledPoints)
219
+                }
220
+            } else {
221
+                currentSegment.append(point)
222
+            }
223
+        }
224
+
225
+        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
226
+        return scaledPoints.isEmpty ? renderPoints : scaledPoints
227
+    }
228
+
229
+    private func appendScaledSegment(
230
+        _ segment: [TimeSeriesChartRenderPoint],
231
+        to scaledPoints: inout [TimeSeriesChartRenderPoint],
232
+        displayColumns: Int
233
+    ) {
234
+        guard !segment.isEmpty else { return }
235
+
236
+        if segment.count <= max(displayColumns * 2, 120) {
237
+            for point in segment {
238
+                appendScaledPoint(point, to: &scaledPoints)
239
+            }
240
+            return
241
+        }
242
+
243
+        var bucket: [TimeSeriesChartRenderPoint] = []
244
+        var currentColumn: Int?
245
+
246
+        for point in segment {
247
+            let column = displayColumn(for: point, totalColumns: displayColumns)
248
+
249
+            if let currentColumn, currentColumn != column {
250
+                appendBucket(bucket, to: &scaledPoints)
251
+                bucket.removeAll(keepingCapacity: true)
252
+            }
253
+
254
+            bucket.append(point)
255
+            currentColumn = column
256
+        }
257
+
258
+        appendBucket(bucket, to: &scaledPoints)
259
+    }
260
+
261
+    private func appendBucket(
262
+        _ bucket: [TimeSeriesChartRenderPoint],
263
+        to scaledPoints: inout [TimeSeriesChartRenderPoint]
264
+    ) {
265
+        guard !bucket.isEmpty else { return }
266
+
267
+        if bucket.count <= 2 {
268
+            for point in bucket {
269
+                appendScaledPoint(point, to: &scaledPoints)
270
+            }
271
+            return
272
+        }
273
+
274
+        let firstPoint = bucket.first!
275
+        let lastPoint = bucket.last!
276
+        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
277
+        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
278
+
279
+        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
280
+            .sorted { lhs, rhs in
281
+                if lhs.timestamp == rhs.timestamp {
282
+                    return lhs.sourceID < rhs.sourceID
283
+                }
284
+                return lhs.timestamp < rhs.timestamp
285
+            }
286
+
287
+        var emittedPointIDs: Set<Int> = []
288
+        for point in orderedPoints where emittedPointIDs.insert(point.sourceID).inserted {
289
+            appendScaledPoint(point, to: &scaledPoints)
290
+        }
291
+    }
292
+
293
+    private func appendScaledPoint(
294
+        _ point: TimeSeriesChartRenderPoint,
295
+        to scaledPoints: inout [TimeSeriesChartRenderPoint]
296
+    ) {
297
+        guard !(scaledPoints.last?.timestamp == point.timestamp &&
298
+                scaledPoints.last?.value == point.value &&
299
+                scaledPoints.last?.kind == point.kind) else {
300
+            return
301
+        }
302
+
303
+        scaledPoints.append(point)
304
+    }
305
+
306
+    private func displayColumn(
307
+        for point: TimeSeriesChartRenderPoint,
308
+        totalColumns: Int
309
+    ) -> Int {
310
+        let totalColumns = max(totalColumns, 1)
311
+        let timeSpan = max(Double(context.size.width), 1)
312
+        let normalizedOffset = min(
313
+            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
314
+            1
315
+        )
316
+
317
+        return min(
318
+            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
319
+            totalColumns - 1
320
+        )
321
+    }
322
+}
323
+
324
+struct TimeSeriesChartHorizontalGuides: View {
325
+    let context: ChartContext
326
+    let labelCount: Int
327
+    var strokeColor: Color = Color.secondary.opacity(0.38)
328
+    var lineWidth: CGFloat = 0.85
329
+
330
+    var body: some View {
331
+        GeometryReader { geometry in
332
+            Path { path in
333
+                for labelIndex in 1...max(labelCount, 1) {
334
+                    let y = context.yGuidePosition(
335
+                        for: labelIndex,
336
+                        of: labelCount,
337
+                        height: geometry.size.height
338
+                    )
339
+                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
340
+                }
341
+            }
342
+            .stroke(strokeColor, lineWidth: lineWidth)
343
+        }
344
+    }
345
+}
346
+
347
+struct TimeSeriesChartVerticalGuides: View {
348
+    let context: ChartContext
349
+    let labelCount: Int
350
+    var visibleLabelRange: Range<Int>? = nil
351
+    var strokeColor: Color = Color.secondary.opacity(0.34)
352
+    var strokeStyle: StrokeStyle = StrokeStyle(lineWidth: 0.8, dash: [4, 4])
353
+
354
+    var body: some View {
355
+        GeometryReader { geometry in
356
+            Path { path in
357
+                for labelIndex in resolvedLabelRange {
358
+                    let x = context.xGuidePosition(
359
+                        for: labelIndex,
360
+                        of: labelCount,
361
+                        width: geometry.size.width
362
+                    )
363
+                    path.move(to: CGPoint(x: x, y: 0))
364
+                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
365
+                }
366
+            }
367
+            .stroke(strokeColor, style: strokeStyle)
368
+        }
369
+    }
370
+
371
+    private var resolvedLabelRange: Range<Int> {
372
+        visibleLabelRange ?? 2..<max(labelCount, 2)
373
+    }
374
+}
375
+
376
+private struct TimeSeriesChartRenderPoint: Hashable {
377
+    let sourceID: Int
378
+    let timestamp: Date
379
+    let value: Double
380
+    let kind: TimeSeriesChartPointKind
381
+
382
+    init<Point: TimeSeriesChartPointRepresentable>(_ point: Point) {
383
+        self.sourceID = point.chartPointID
384
+        self.timestamp = point.chartTimestamp
385
+        self.value = point.chartValue
386
+        self.kind = point.chartPointKind
387
+    }
388
+
389
+    var isSample: Bool {
390
+        kind == .sample
391
+    }
392
+
393
+    var isDiscontinuity: Bool {
394
+        kind == .discontinuity
395
+    }
396
+
397
+    var cgPoint: CGPoint {
398
+        CGPoint(x: timestamp.timeIntervalSince1970, y: value)
399
+    }
400
+}
401
+
402
+extension ChartContext {
403
+    func yGuidePosition(
404
+        for labelIndex: Int,
405
+        of labelCount: Int,
406
+        height: CGFloat
407
+    ) -> CGFloat {
408
+        let value = yAxisLabel(for: labelIndex, of: max(labelCount, 2))
409
+        let anchorPoint = CGPoint(x: origin.x, y: CGFloat(value))
410
+        return placeInRect(point: anchorPoint).y * height
411
+    }
412
+
413
+    func xGuidePosition(
414
+        for labelIndex: Int,
415
+        of labelCount: Int,
416
+        width: CGFloat
417
+    ) -> CGFloat {
418
+        let value = xAxisLabel(for: labelIndex, of: max(labelCount, 2))
419
+        let anchorPoint = CGPoint(x: CGFloat(value), y: origin.y)
420
+        return placeInRect(point: anchorPoint).x * width
421
+    }
422
+}
+119 -300
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -13,6 +13,11 @@ private enum PresentTrackingMode: CaseIterable, Hashable {
13 13
     case keepStartTimestamp
14 14
 }
15 15
 
16
+enum MeasurementChartSizing {
17
+    case provided(size: CGSize, compact: Bool)
18
+    case embedded
19
+}
20
+
16 21
 enum MeasurementChartSelectorActionTone {
17 22
     case reversible
18 23
     case destructive
@@ -21,18 +26,52 @@ enum MeasurementChartSelectorActionTone {
21 26
 
22 27
 struct MeasurementChartSelectionAction {
23 28
     let title: String
29
+    let shortTitle: String?
24 30
     let systemName: String
25 31
     let tone: MeasurementChartSelectorActionTone
26 32
     let handler: (ClosedRange<Date>) -> Void
33
+
34
+    init(
35
+        title: String,
36
+        shortTitle: String? = nil,
37
+        systemName: String,
38
+        tone: MeasurementChartSelectorActionTone,
39
+        handler: @escaping (ClosedRange<Date>) -> Void
40
+    ) {
41
+        self.title = title
42
+        self.shortTitle = shortTitle
43
+        self.systemName = systemName
44
+        self.tone = tone
45
+        self.handler = handler
46
+    }
27 47
 }
28 48
 
29 49
 struct MeasurementChartResetAction {
30 50
     let title: String
51
+    let shortTitle: String?
31 52
     let systemName: String
32 53
     let tone: MeasurementChartSelectorActionTone
33 54
     let confirmationTitle: String
34 55
     let confirmationButtonTitle: String
35 56
     let handler: () -> Void
57
+
58
+    init(
59
+        title: String,
60
+        shortTitle: String? = nil,
61
+        systemName: String,
62
+        tone: MeasurementChartSelectorActionTone,
63
+        confirmationTitle: String,
64
+        confirmationButtonTitle: String,
65
+        handler: @escaping () -> Void
66
+    ) {
67
+        self.title = title
68
+        self.shortTitle = shortTitle
69
+        self.systemName = systemName
70
+        self.tone = tone
71
+        self.confirmationTitle = confirmationTitle
72
+        self.confirmationButtonTitle = confirmationButtonTitle
73
+        self.handler = handler
74
+    }
36 75
 }
37 76
 
38 77
 struct MeasurementChartRangeSelectorConfiguration {
@@ -123,20 +162,37 @@ struct MeasurementChartView: View {
123 162
     private let defaultEmptyChartTimeSpan: TimeInterval = 60
124 163
     private let selectorTint: Color = .blue
125 164
 
126
-    let compactLayout: Bool
127
-    let availableSize: CGSize
165
+    let sizing: MeasurementChartSizing
128 166
     let showsRangeSelector: Bool
129 167
     let rebasesEnergyToVisibleRangeStart: Bool
130 168
     let extendsTimelineToPresent: Bool
131 169
     let rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration?
132
-    
170
+
133 171
     @EnvironmentObject private var measurements: Measurements
134 172
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
135 173
     @Environment(\.verticalSizeClass) private var verticalSizeClass
136 174
     var timeRange: ClosedRange<Date>? = nil
137 175
     let timeRangeLowerBound: Date?
138 176
     let timeRangeUpperBound: Date?
139
-    
177
+
178
+    @State private var embeddedWidth: CGFloat = 760
179
+
180
+    private var compactLayout: Bool {
181
+        switch sizing {
182
+        case .provided(_, let compact): return compact
183
+        case .embedded: return embeddedWidth < 760
184
+        }
185
+    }
186
+
187
+    private var availableSize: CGSize {
188
+        switch sizing {
189
+        case .provided(let size, _): return size
190
+        case .embedded:
191
+            let h = compactLayout ? 290 : 350
192
+            return CGSize(width: embeddedWidth, height: CGFloat(h))
193
+        }
194
+    }
195
+
140 196
     @State var displayVoltage: Bool = false
141 197
     @State var displayCurrent: Bool = false
142 198
     @State var displayPower: Bool = true
@@ -160,8 +216,7 @@ struct MeasurementChartView: View {
160 216
     let yLabels: Int = 4
161 217
 
162 218
     init(
163
-        compactLayout: Bool = false,
164
-        availableSize: CGSize = .zero,
219
+        sizing: MeasurementChartSizing = .embedded,
165 220
         timeRange: ClosedRange<Date>? = nil,
166 221
         timeRangeLowerBound: Date? = nil,
167 222
         timeRangeUpperBound: Date? = nil,
@@ -170,8 +225,7 @@ struct MeasurementChartView: View {
170 225
         extendsTimelineToPresent: Bool = true,
171 226
         rangeSelectorConfiguration: MeasurementChartRangeSelectorConfiguration? = nil
172 227
     ) {
173
-        self.compactLayout = compactLayout
174
-        self.availableSize = availableSize
228
+        self.sizing = sizing
175 229
         self.timeRange = timeRange
176 230
         self.timeRangeLowerBound = timeRangeLowerBound
177 231
         self.timeRangeUpperBound = timeRangeUpperBound
@@ -181,19 +235,11 @@ struct MeasurementChartView: View {
181 235
         self.rangeSelectorConfiguration = rangeSelectorConfiguration
182 236
     }
183 237
 
184
-    static func prefersCompactEmbeddedLayout(forWidth width: CGFloat) -> Bool {
185
-        width < 760
186
-    }
187
-
188
-    static func embeddedPlotReferenceHeight(compactLayout: Bool) -> CGFloat {
189
-        compactLayout ? 290 : 350
190
-    }
191
-
192
-    static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
193
-        let compactLayout = prefersCompactEmbeddedLayout(forWidth: width)
194
-        let plotHeight = embeddedPlotReferenceHeight(compactLayout: compactLayout)
238
+    private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
239
+        let compact = width < 760
240
+        let plotHeight: CGFloat = compact ? 290 : 350
195 241
         guard showsRangeSelector else { return plotHeight }
196
-        return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compactLayout)
242
+        return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact)
197 243
     }
198 244
 
199 245
     private var axisColumnWidth: CGFloat {
@@ -305,6 +351,28 @@ struct MeasurementChartView: View {
305 351
     }
306 352
 
307 353
     var body: some View {
354
+        switch sizing {
355
+        case .provided:
356
+            chartBody
357
+        case .embedded:
358
+            let chartWidth = max(embeddedWidth, 1)
359
+            chartBody
360
+                .frame(maxWidth: .infinity, alignment: .topLeading)
361
+                .frame(height: Self.embeddedContentHeight(width: chartWidth, showsRangeSelector: showsRangeSelector))
362
+                .background(
363
+                    GeometryReader { geometry in
364
+                        Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width)
365
+                    }
366
+                )
367
+                .onPreferenceChange(EmbeddedWidthKey.self) { width in
368
+                    guard width > 0, abs(width - embeddedWidth) > 0.5 else { return }
369
+                    embeddedWidth = width
370
+                }
371
+        }
372
+    }
373
+
374
+    @ViewBuilder
375
+    private var chartBody: some View {
308 376
         let availableTimeRange = availableSelectionTimeRange()
309 377
         let visibleTimeRange = resolvedVisibleTimeRange(within: availableTimeRange)
310 378
         let powerSeries = series(
@@ -809,19 +877,22 @@ struct MeasurementChartView: View {
809 877
 
810 878
         return MeasurementChartRangeSelectorConfiguration(
811 879
             keepAction: MeasurementChartSelectionAction(
812
-                title: compactLayout ? "Keep" : "Keep Selection",
880
+                title: "Keep Selection",
881
+                shortTitle: "Keep",
813 882
                 systemName: "scissors",
814 883
                 tone: .destructive,
815 884
                 handler: trimBufferToSelection
816 885
             ),
817 886
             removeAction: MeasurementChartSelectionAction(
818
-                title: compactLayout ? "Cut" : "Remove Selection",
887
+                title: "Remove Selection",
888
+                shortTitle: "Cut",
819 889
                 systemName: "minus.circle",
820 890
                 tone: .destructive,
821 891
                 handler: removeSelectionFromBuffer
822 892
             ),
823 893
             resetAction: MeasurementChartResetAction(
824
-                title: compactLayout ? "Reset" : "Reset Buffer",
894
+                title: "Reset Buffer",
895
+                shortTitle: "Reset",
825 896
                 systemName: "trash",
826 897
                 tone: .destructiveProminent,
827 898
                 confirmationTitle: "Reset captured measurements?",
@@ -897,24 +968,24 @@ struct MeasurementChartView: View {
897 968
         temperatureSeries: SeriesData
898 969
     ) -> some View {
899 970
         if self.displayPower {
900
-            Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
971
+            TimeSeriesChart(points: powerSeries.points, context: powerSeries.context, strokeColor: powerSeries.kind.tint)
901 972
                 .opacity(0.72)
902 973
         } else if self.displayEnergy {
903
-            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
974
+            TimeSeriesChart(points: energySeries.points, context: energySeries.context, strokeColor: energySeries.kind.tint)
904 975
                 .opacity(0.78)
905 976
         } else {
906 977
             if self.displayVoltage {
907
-                Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
978
+                TimeSeriesChart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: voltageSeries.kind.tint)
908 979
                     .opacity(0.78)
909 980
             }
910 981
             if self.displayCurrent {
911
-                Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
982
+                TimeSeriesChart(points: currentSeries.points, context: currentSeries.context, strokeColor: currentSeries.kind.tint)
912 983
                     .opacity(0.78)
913 984
             }
914 985
         }
915 986
 
916 987
         if displayTemperature {
917
-            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
988
+            TimeSeriesChart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: temperatureSeries.kind.tint)
918 989
                 .opacity(0.86)
919 990
         }
920 991
     }
@@ -1767,9 +1838,7 @@ struct MeasurementChartView: View {
1767 1838
         context: ChartContext,
1768 1839
         height: CGFloat
1769 1840
     ) -> CGFloat {
1770
-        let value = context.yAxisLabel(for: labelIndex, of: yLabels)
1771
-        let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
1772
-        return context.placeInRect(point: anchorPoint).y * height
1841
+        context.yGuidePosition(for: labelIndex, of: yLabels, height: height)
1773 1842
     }
1774 1843
 
1775 1844
     private func xGuidePosition(
@@ -1777,9 +1846,7 @@ struct MeasurementChartView: View {
1777 1846
         context: ChartContext,
1778 1847
         width: CGFloat
1779 1848
     ) -> CGFloat {
1780
-        let value = context.xAxisLabel(for: labelIndex, of: xLabels)
1781
-        let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
1782
-        return context.placeInRect(point: anchorPoint).x * width
1849
+        context.xGuidePosition(for: labelIndex, of: xLabels, width: width)
1783 1850
     }
1784 1851
     
1785 1852
     // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
@@ -1932,37 +1999,11 @@ struct MeasurementChartView: View {
1932 1999
     }
1933 2000
     
1934 2001
     fileprivate func horizontalGuides(context: ChartContext) -> some View {
1935
-        GeometryReader { geometry in
1936
-            Path { path in
1937
-                for labelIndex in 1...self.yLabels {
1938
-                    let y = yGuidePosition(
1939
-                        for: labelIndex,
1940
-                        context: context,
1941
-                        height: geometry.size.height
1942
-                    )
1943
-                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
1944
-                }
1945
-            }
1946
-            .stroke(Color.secondary.opacity(0.38), lineWidth: 0.85)
1947
-        }
2002
+        TimeSeriesChartHorizontalGuides(context: context, labelCount: yLabels)
1948 2003
     }
1949 2004
     
1950 2005
     fileprivate func verticalGuides(context: ChartContext) -> some View {
1951
-        GeometryReader { geometry in
1952
-            Path { path in
1953
-                
1954
-                for labelIndex in 2..<self.xLabels {
1955
-                    let x = xGuidePosition(
1956
-                        for: labelIndex,
1957
-                        context: context,
1958
-                        width: geometry.size.width
1959
-                    )
1960
-                    path.move(to: CGPoint(x: x, y: 0) )
1961
-                    path.addLine(to: CGPoint(x: path.currentPoint!.x, y: geometry.size.height) )
1962
-                }
1963
-            }
1964
-            .stroke(Color.secondary.opacity(0.34), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
1965
-        }
2006
+        TimeSeriesChartVerticalGuides(context: context, labelCount: xLabels)
1966 2007
     }
1967 2008
 
1968 2009
     fileprivate func discontinuityMarkers(
@@ -1988,6 +2029,14 @@ struct MeasurementChartView: View {
1988 2029
     
1989 2030
 }
1990 2031
 
2032
+private struct EmbeddedWidthKey: PreferenceKey {
2033
+    static let defaultValue: CGFloat = 760
2034
+    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
2035
+        let next = nextValue()
2036
+        if next > 0 { value = next }
2037
+    }
2038
+}
2039
+
1991 2040
 private struct TimeRangeSelectorView: View {
1992 2041
     private enum DragTarget {
1993 2042
         case lowerBound
@@ -2078,6 +2127,7 @@ private struct TimeRangeSelectorView: View {
2078 2127
                 if !coversFullRange {
2079 2128
                     actionButton(
2080 2129
                         title: configuration.keepAction.title,
2130
+                        shortTitle: configuration.keepAction.shortTitle,
2081 2131
                         systemName: configuration.keepAction.systemName,
2082 2132
                         tone: configuration.keepAction.tone,
2083 2133
                         action: {
@@ -2089,6 +2139,7 @@ private struct TimeRangeSelectorView: View {
2089 2139
                     if let removeAction = configuration.removeAction {
2090 2140
                         actionButton(
2091 2141
                             title: removeAction.title,
2142
+                            shortTitle: removeAction.shortTitle,
2092 2143
                             systemName: removeAction.systemName,
2093 2144
                             tone: removeAction.tone,
2094 2145
                             action: {
@@ -2103,6 +2154,7 @@ private struct TimeRangeSelectorView: View {
2103 2154
 
2104 2155
                 actionButton(
2105 2156
                     title: configuration.resetAction.title,
2157
+                    shortTitle: configuration.resetAction.shortTitle,
2106 2158
                     systemName: configuration.resetAction.systemName,
2107 2159
                     tone: configuration.resetAction.tone,
2108 2160
                     action: {
@@ -2126,7 +2178,7 @@ private struct TimeRangeSelectorView: View {
2126 2178
                     RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
2127 2179
                         .fill(Color.primary.opacity(0.05))
2128 2180
 
2129
-                    Chart(
2181
+                    TimeSeriesChart(
2130 2182
                         points: points,
2131 2183
                         context: context,
2132 2184
                         areaChart: true,
@@ -2136,7 +2188,7 @@ private struct TimeRangeSelectorView: View {
2136 2188
                     .opacity(0.94)
2137 2189
                     .allowsHitTesting(false)
2138 2190
 
2139
-                    Chart(
2191
+                    TimeSeriesChart(
2140 2192
                         points: points,
2141 2193
                         context: context,
2142 2194
                         strokeColor: selectorTint.opacity(0.56)
@@ -2260,6 +2312,7 @@ private struct TimeRangeSelectorView: View {
2260 2312
 
2261 2313
     private func actionButton(
2262 2314
         title: String,
2315
+        shortTitle: String? = nil,
2263 2316
         systemName: String,
2264 2317
         tone: MeasurementChartSelectorActionTone,
2265 2318
         action: @escaping () -> Void
@@ -2272,9 +2325,10 @@ private struct TimeRangeSelectorView: View {
2272 2325
                 return .white
2273 2326
             }
2274 2327
         }()
2328
+        let displayTitle = (compactLayout ? shortTitle : nil) ?? title
2275 2329
 
2276 2330
         return Button(action: action) {
2277
-            Label(title, systemImage: systemName)
2331
+            Label(displayTitle, systemImage: systemName)
2278 2332
                 .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2279 2333
                 .padding(.horizontal, compactLayout ? 10 : 12)
2280 2334
                 .padding(.vertical, compactLayout ? 7 : 8)
@@ -2691,238 +2745,3 @@ private struct TimeRangeSelectorView: View {
2691 2745
         }
2692 2746
     }
2693 2747
 }
2694
-
2695
-struct Chart : View {
2696
-    
2697
-    @Environment(\.displayScale) private var displayScale
2698
-
2699
-    let points: [Measurements.Measurement.Point]
2700
-    let context: ChartContext
2701
-    var areaChart: Bool = false
2702
-    var strokeColor: Color = .black
2703
-    var areaFillColor: Color? = nil
2704
-    
2705
-    var body : some View {
2706
-        GeometryReader { geometry in
2707
-            if self.areaChart {
2708
-                let fillColor = areaFillColor ?? strokeColor.opacity(0.2)
2709
-                self.path( geometry: geometry )
2710
-                    .fill(
2711
-                        LinearGradient(
2712
-                            gradient: .init(
2713
-                                colors: [
2714
-                                    fillColor.opacity(0.72),
2715
-                                    fillColor.opacity(0.18)
2716
-                                ]
2717
-                            ),
2718
-                            startPoint: .init(x: 0.5, y: 0.08),
2719
-                            endPoint: .init(x: 0.5, y: 0.92)
2720
-                        )
2721
-                    )
2722
-            } else {
2723
-                self.path( geometry: geometry )
2724
-                    .stroke(self.strokeColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
2725
-            }
2726
-        }
2727
-    }
2728
-    
2729
-    fileprivate func path(geometry: GeometryProxy) -> Path {
2730
-        let displayedPoints = scaledPoints(for: geometry.size.width)
2731
-        let baselineY = context.placeInRect(
2732
-            point: CGPoint(x: context.origin.x, y: context.origin.y)
2733
-        ).y * geometry.size.height
2734
-
2735
-        return Path { path in
2736
-            var firstRenderedPoint: CGPoint?
2737
-            var lastRenderedPoint: CGPoint?
2738
-            var needsMove = true
2739
-
2740
-            for point in displayedPoints {
2741
-                if point.isDiscontinuity {
2742
-                    closeAreaSegment(
2743
-                        in: &path,
2744
-                        firstPoint: firstRenderedPoint,
2745
-                        lastPoint: lastRenderedPoint,
2746
-                        baselineY: baselineY
2747
-                    )
2748
-                    firstRenderedPoint = nil
2749
-                    lastRenderedPoint = nil
2750
-                    needsMove = true
2751
-                    continue
2752
-                }
2753
-
2754
-                let item = context.placeInRect(point: point.point())
2755
-                let renderedPoint = CGPoint(
2756
-                    x: item.x * geometry.size.width,
2757
-                    y: item.y * geometry.size.height
2758
-                )
2759
-
2760
-                if needsMove {
2761
-                    path.move(to: renderedPoint)
2762
-                    firstRenderedPoint = renderedPoint
2763
-                    needsMove = false
2764
-                } else {
2765
-                    path.addLine(to: renderedPoint)
2766
-                }
2767
-
2768
-                lastRenderedPoint = renderedPoint
2769
-            }
2770
-
2771
-            closeAreaSegment(
2772
-                in: &path,
2773
-                firstPoint: firstRenderedPoint,
2774
-                lastPoint: lastRenderedPoint,
2775
-                baselineY: baselineY
2776
-            )
2777
-        }
2778
-    }
2779
-
2780
-    private func closeAreaSegment(
2781
-        in path: inout Path,
2782
-        firstPoint: CGPoint?,
2783
-        lastPoint: CGPoint?,
2784
-        baselineY: CGFloat
2785
-    ) {
2786
-        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2787
-
2788
-        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2789
-        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2790
-        path.closeSubpath()
2791
-    }
2792
-
2793
-    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2794
-        let sampleCount = points.reduce(into: 0) { partialResult, point in
2795
-            if point.isSample {
2796
-                partialResult += 1
2797
-            }
2798
-        }
2799
-
2800
-        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2801
-        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2802
-
2803
-        guard sampleCount > maximumSamplesToRender, context.isValid else {
2804
-            return points
2805
-        }
2806
-
2807
-        var scaledPoints: [Measurements.Measurement.Point] = []
2808
-        var currentSegment: [Measurements.Measurement.Point] = []
2809
-
2810
-        for point in points {
2811
-            if point.isDiscontinuity {
2812
-                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2813
-                currentSegment.removeAll(keepingCapacity: true)
2814
-
2815
-                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2816
-                    appendScaledPoint(point, to: &scaledPoints)
2817
-                }
2818
-            } else {
2819
-                currentSegment.append(point)
2820
-            }
2821
-        }
2822
-
2823
-        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2824
-        return scaledPoints.isEmpty ? points : scaledPoints
2825
-    }
2826
-
2827
-    private func appendScaledSegment(
2828
-        _ segment: [Measurements.Measurement.Point],
2829
-        to scaledPoints: inout [Measurements.Measurement.Point],
2830
-        displayColumns: Int
2831
-    ) {
2832
-        guard !segment.isEmpty else { return }
2833
-
2834
-        if segment.count <= max(displayColumns * 2, 120) {
2835
-            for point in segment {
2836
-                appendScaledPoint(point, to: &scaledPoints)
2837
-            }
2838
-            return
2839
-        }
2840
-
2841
-        var bucket: [Measurements.Measurement.Point] = []
2842
-        var currentColumn: Int?
2843
-
2844
-        for point in segment {
2845
-            let column = displayColumn(for: point, totalColumns: displayColumns)
2846
-
2847
-            if let currentColumn, currentColumn != column {
2848
-                appendBucket(bucket, to: &scaledPoints)
2849
-                bucket.removeAll(keepingCapacity: true)
2850
-            }
2851
-
2852
-            bucket.append(point)
2853
-            currentColumn = column
2854
-        }
2855
-
2856
-        appendBucket(bucket, to: &scaledPoints)
2857
-    }
2858
-
2859
-    private func appendBucket(
2860
-        _ bucket: [Measurements.Measurement.Point],
2861
-        to scaledPoints: inout [Measurements.Measurement.Point]
2862
-    ) {
2863
-        guard !bucket.isEmpty else { return }
2864
-
2865
-        if bucket.count <= 2 {
2866
-            for point in bucket {
2867
-                appendScaledPoint(point, to: &scaledPoints)
2868
-            }
2869
-            return
2870
-        }
2871
-
2872
-        let firstPoint = bucket.first!
2873
-        let lastPoint = bucket.last!
2874
-        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2875
-        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2876
-
2877
-        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2878
-            .sorted { lhs, rhs in
2879
-                if lhs.timestamp == rhs.timestamp {
2880
-                    return lhs.id < rhs.id
2881
-                }
2882
-                return lhs.timestamp < rhs.timestamp
2883
-            }
2884
-
2885
-        var emittedPointIDs: Set<Int> = []
2886
-        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2887
-            appendScaledPoint(point, to: &scaledPoints)
2888
-        }
2889
-    }
2890
-
2891
-    private func appendScaledPoint(
2892
-        _ point: Measurements.Measurement.Point,
2893
-        to scaledPoints: inout [Measurements.Measurement.Point]
2894
-    ) {
2895
-        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2896
-                scaledPoints.last?.value == point.value &&
2897
-                scaledPoints.last?.kind == point.kind) else {
2898
-            return
2899
-        }
2900
-
2901
-        scaledPoints.append(
2902
-            Measurements.Measurement.Point(
2903
-                id: scaledPoints.count,
2904
-                timestamp: point.timestamp,
2905
-                value: point.value,
2906
-                kind: point.kind
2907
-            )
2908
-        )
2909
-    }
2910
-
2911
-    private func displayColumn(
2912
-        for point: Measurements.Measurement.Point,
2913
-        totalColumns: Int
2914
-    ) -> Int {
2915
-        let totalColumns = max(totalColumns, 1)
2916
-        let timeSpan = max(Double(context.size.width), 1)
2917
-        let normalizedOffset = min(
2918
-            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2919
-            1
2920
-        )
2921
-
2922
-        return min(
2923
-            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2924
-            totalColumns - 1
2925
-        )
2926
-    }
2927
-    
2928
-}
+4 -29
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -15,17 +15,6 @@ struct MeterChargeRecordTabView: View, Equatable {
15 15
     }
16 16
 }
17 17
 
18
-private struct SessionChartWidthPreferenceKey: PreferenceKey {
19
-    static let defaultValue: CGFloat = 760
20
-
21
-    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
22
-        let next = nextValue()
23
-        if next > 0 {
24
-            value = next
25
-        }
26
-    }
27
-}
28
-
29 18
 struct MeterChargeRecordContentView: View {
30 19
     private struct SessionMetricRow {
31 20
         let label: String
@@ -126,7 +115,6 @@ struct MeterChargeRecordContentView: View {
126 115
     @State private var activeMode: ActiveMode = .chargeSession
127 116
     @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow?
128 117
     @State private var trimBannerDismissedForSessionID: UUID?
129
-    @State private var sessionChartWidth: CGFloat = 760
130 118
 
131 119
     private var shouldShowTrimBanner: Bool {
132 120
         guard let session = openChargeSession,
@@ -1393,9 +1381,6 @@ struct MeterChargeRecordContentView: View {
1393 1381
 
1394 1382
     private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
1395 1383
         let hasRangeSelector = session.aggregatedSamples.isEmpty == false
1396
-        let chartWidth = max(sessionChartWidth, 1)
1397
-        let compactChartLayout = MeasurementChartView.prefersCompactEmbeddedLayout(forWidth: chartWidth)
1398
-        let plotReferenceHeight = MeasurementChartView.embeddedPlotReferenceHeight(compactLayout: compactChartLayout)
1399 1384
 
1400 1385
         return VStack(alignment: .leading, spacing: 12) {
1401 1386
             HStack(spacing: 8) {
@@ -1413,8 +1398,6 @@ struct MeterChargeRecordContentView: View {
1413 1398
             }
1414 1399
 
1415 1400
             MeasurementChartView(
1416
-                compactLayout: compactChartLayout,
1417
-                availableSize: CGSize(width: chartWidth, height: plotReferenceHeight),
1418 1401
                 timeRange: timeRange,
1419 1402
                 timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower,
1420 1403
                 timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper,
@@ -1424,7 +1407,8 @@ struct MeterChargeRecordContentView: View {
1424 1407
                 rangeSelectorConfiguration: hasRangeSelector
1425 1408
                     ? MeasurementChartRangeSelectorConfiguration(
1426 1409
                         keepAction: MeasurementChartSelectionAction(
1427
-                            title: compactChartLayout ? "Keep" : "Keep Selection",
1410
+                            title: "Keep Selection",
1411
+                            shortTitle: "Keep",
1428 1412
                             systemName: "scissors",
1429 1413
                             tone: .destructive,
1430 1414
                             handler: { range in
@@ -1438,7 +1422,8 @@ struct MeterChargeRecordContentView: View {
1438 1422
                         ),
1439 1423
                         removeAction: nil,
1440 1424
                         resetAction: MeasurementChartResetAction(
1441
-                            title: compactChartLayout ? "Reset" : "Reset Trim",
1425
+                            title: "Reset Trim",
1426
+                            shortTitle: "Reset",
1442 1427
                             systemName: "arrow.counterclockwise",
1443 1428
                             tone: .reversible,
1444 1429
                             confirmationTitle: "Reset session trim?",
@@ -1452,16 +1437,6 @@ struct MeterChargeRecordContentView: View {
1452 1437
             )
1453 1438
             .environmentObject(usbMeter.chargeRecordMeasurements)
1454 1439
             .frame(maxWidth: .infinity, alignment: .topLeading)
1455
-            .frame(height: MeasurementChartView.embeddedContentHeight(width: chartWidth, showsRangeSelector: hasRangeSelector))
1456
-            .background(
1457
-                GeometryReader { geometry in
1458
-                    Color.clear.preference(key: SessionChartWidthPreferenceKey.self, value: geometry.size.width)
1459
-                }
1460
-            )
1461
-            .onPreferenceChange(SessionChartWidthPreferenceKey.self) { width in
1462
-                guard width > 0, abs(width - sessionChartWidth) > 0.5 else { return }
1463
-                sessionChartWidth = width
1464
-            }
1465 1440
         }
1466 1441
         .padding(18)
1467 1442
         .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
+1 -2
USB Meter/Views/Meter/Tabs/ChargeRecord/SessionTrimEditorView.swift
@@ -131,8 +131,7 @@ struct SessionTrimEditorView: View {
131 131
                 ZStack(alignment: .topLeading) {
132 132
                     // Background chart — full session
133 133
                     MeasurementChartView(
134
-                        compactLayout: true,
135
-                        availableSize: geo.size,
134
+                        sizing: .provided(size: geo.size, compact: true),
136 135
                         timeRange: fullStart...fullEnd,
137 136
                         showsRangeSelector: false,
138 137
                         rebasesEnergyToVisibleRangeStart: false
+2 -8
USB Meter/Views/Meter/Tabs/Chart/MeterChartTabView.swift
@@ -28,10 +28,7 @@ struct MeterChartTabView: View {
28 28
         Group {
29 29
             if isLandscape {
30 30
                 landscapeFace {
31
-                    MeasurementChartView(
32
-                        compactLayout: prefersCompactLandscapeLayout,
33
-                        availableSize: size
34
-                    )
31
+                    MeasurementChartView(sizing: .provided(size: size, compact: prefersCompactLandscapeLayout))
35 32
                         .environmentObject(meter.measurements)
36 33
                         .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
37 34
                         .padding(10)
@@ -40,10 +37,7 @@ struct MeterChartTabView: View {
40 37
                 }
41 38
             } else {
42 39
                 portraitFace {
43
-                    MeasurementChartView(
44
-                        compactLayout: prefersCompactPortraitLayout,
45
-                        availableSize: size
46
-                    )
40
+                    MeasurementChartView(sizing: .provided(size: size, compact: prefersCompactPortraitLayout))
47 41
                         .environmentObject(meter.measurements)
48 42
                         .padding(.horizontal, portraitContentCardHorizontalPadding)
49 43
                         .padding(.vertical, portraitContentCardVerticalPadding)
+13 -24
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -615,7 +615,7 @@ private struct RSSIHistorySheetView: View {
615 615
 
616 616
                                         rssiHorizontalGuides(context: chartContext)
617 617
                                         rssiVerticalGuides(context: chartContext)
618
-                                        Chart(points: points, context: chartContext, strokeColor: .mint)
618
+                                        TimeSeriesChart(points: points, context: chartContext, strokeColor: .mint)
619 619
                                             .opacity(0.82)
620 620
                                     }
621 621
                                     .frame(maxWidth: .infinity)
@@ -716,32 +716,21 @@ private struct RSSIHistorySheetView: View {
716 716
     }
717 717
 
718 718
     private func rssiHorizontalGuides(context: ChartContext) -> some View {
719
-        GeometryReader { geometry in
720
-            Path { path in
721
-                for labelIndex in 1...yLabels {
722
-                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
723
-                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
724
-                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
725
-                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
726
-                }
727
-            }
728
-            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
729
-        }
719
+        TimeSeriesChartHorizontalGuides(
720
+            context: context,
721
+            labelCount: yLabels,
722
+            strokeColor: Color.secondary.opacity(0.30),
723
+            lineWidth: 0.8
724
+        )
730 725
     }
731 726
 
732 727
     private func rssiVerticalGuides(context: ChartContext) -> some View {
733
-        GeometryReader { geometry in
734
-            Path { path in
735
-                for labelIndex in 2..<xLabels {
736
-                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
737
-                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
738
-                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
739
-                    path.move(to: CGPoint(x: x, y: 0))
740
-                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
741
-                }
742
-            }
743
-            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
744
-        }
728
+        TimeSeriesChartVerticalGuides(
729
+            context: context,
730
+            labelCount: xLabels,
731
+            strokeColor: Color.secondary.opacity(0.26),
732
+            strokeStyle: StrokeStyle(lineWidth: 0.8, dash: [4, 4])
733
+        )
745 734
     }
746 735
 }
747 736