@@ -115,10 +115,14 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 115 | 115 |
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
| 116 | 116 |
managerState = central.state; |
| 117 | 117 |
track("\(central.state)")
|
| 118 |
+ for meter in appData.meters.values {
|
|
| 119 |
+ meter.btSerial.centralStateChanged(to: central.state) |
|
| 120 |
+ } |
|
| 118 | 121 |
|
| 119 | 122 |
switch central.state {
|
| 120 | 123 |
case .poweredOff: |
| 121 | 124 |
scanStartedAt = nil |
| 125 |
+ advertisementDataCache.clear() |
|
| 122 | 126 |
track("Bluetooth is Off. How should I behave?")
|
| 123 | 127 |
case .poweredOn: |
| 124 | 128 |
scanStartedAt = Date() |
@@ -129,18 +133,23 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 129 | 133 |
scanForMeters() |
| 130 | 134 |
case .resetting: |
| 131 | 135 |
scanStartedAt = nil |
| 136 |
+ advertisementDataCache.clear() |
|
| 132 | 137 |
track("Bluetooth is reseting... . Whatever that means.")
|
| 133 | 138 |
case .unauthorized: |
| 134 | 139 |
scanStartedAt = nil |
| 140 |
+ advertisementDataCache.clear() |
|
| 135 | 141 |
track("Bluetooth is not authorized.")
|
| 136 | 142 |
case .unknown: |
| 137 | 143 |
scanStartedAt = nil |
| 144 |
+ advertisementDataCache.clear() |
|
| 138 | 145 |
track("Bluetooth is in an unknown state.")
|
| 139 | 146 |
case .unsupported: |
| 140 | 147 |
scanStartedAt = nil |
| 148 |
+ advertisementDataCache.clear() |
|
| 141 | 149 |
track("Bluetooth not supported by device")
|
| 142 | 150 |
default: |
| 143 | 151 |
scanStartedAt = nil |
| 152 |
+ advertisementDataCache.clear() |
|
| 144 | 153 |
track("Bluetooth is in a state never seen before!")
|
| 145 | 154 |
} |
| 146 | 155 |
} |
@@ -108,9 +108,22 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 108 | 108 |
notifyCharacteristic = nil |
| 109 | 109 |
} |
| 110 | 110 |
} |
| 111 |
+ |
|
| 112 |
+ private func forceNotConnected(reason: String, clearCharacteristics: Bool = true) {
|
|
| 113 |
+ resetCommunicationState(reason: reason, clearCharacteristics: clearCharacteristics) |
|
| 114 |
+ guard operationalState != .peripheralNotConnected else {
|
|
| 115 |
+ return |
|
| 116 |
+ } |
|
| 117 |
+ operationalState = .peripheralNotConnected |
|
| 118 |
+ } |
|
| 111 | 119 |
|
| 112 | 120 |
func connect() {
|
| 113 | 121 |
administrativeState = .up |
| 122 |
+ guard manager.state == .poweredOn else {
|
|
| 123 |
+ track("Connect requested for '\(peripheral.identifier)' but central state is \(manager.state)")
|
|
| 124 |
+ forceNotConnected(reason: "connect() while central is \(manager.state)") |
|
| 125 |
+ return |
|
| 126 |
+ } |
|
| 114 | 127 |
if operationalState < .peripheralConnected {
|
| 115 | 128 |
resetCommunicationState(reason: "connect()", clearCharacteristics: true) |
| 116 | 129 |
operationalState = .peripheralConnectionPending |
@@ -126,6 +139,11 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 126 | 139 |
resetCommunicationState(reason: "disconnect()", clearCharacteristics: true) |
| 127 | 140 |
if peripheral.state != .disconnected || operationalState != .peripheralNotConnected {
|
| 128 | 141 |
track("Disconnect requested for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
|
| 142 |
+ guard manager.state == .poweredOn else {
|
|
| 143 |
+ track("Skipping central cancel for '\(peripheral.identifier)' because central state is \(manager.state)")
|
|
| 144 |
+ forceNotConnected(reason: "disconnect() while central is \(manager.state)", clearCharacteristics: false) |
|
| 145 |
+ return |
|
| 146 |
+ } |
|
| 129 | 147 |
manager.cancelPeripheralConnection(peripheral) |
| 130 | 148 |
} |
| 131 | 149 |
} |
@@ -186,6 +204,26 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 186 | 204 |
operationalState = .peripheralNotConnected |
| 187 | 205 |
} |
| 188 | 206 |
|
| 207 |
+ func centralStateChanged(to newState: CBManagerState) {
|
|
| 208 |
+ switch newState {
|
|
| 209 |
+ case .poweredOn: |
|
| 210 |
+ if administrativeState == .up, |
|
| 211 |
+ operationalState == .peripheralNotConnected, |
|
| 212 |
+ peripheral.state == .disconnected {
|
|
| 213 |
+ track("Central returned to poweredOn. Restoring connection to '\(peripheral.identifier)'")
|
|
| 214 |
+ connect() |
|
| 215 |
+ } |
|
| 216 |
+ case .poweredOff, .resetting, .unauthorized, .unknown, .unsupported: |
|
| 217 |
+ if operationalState != .peripheralNotConnected || expectedResponseLength != 0 || !buffer.isEmpty {
|
|
| 218 |
+ track("Central changed to \(newState). Forcing '\(peripheral.identifier)' to not connected.")
|
|
| 219 |
+ } |
|
| 220 |
+ forceNotConnected(reason: "centralStateChanged(\(newState))") |
|
| 221 |
+ @unknown default: |
|
| 222 |
+ track("Central changed to an unknown state. Forcing '\(peripheral.identifier)' to not connected.")
|
|
| 223 |
+ forceNotConnected(reason: "centralStateChanged(@unknown default)") |
|
| 224 |
+ } |
|
| 225 |
+ } |
|
| 226 |
+ |
|
| 189 | 227 |
func setWDT() {
|
| 190 | 228 |
wdTimer?.invalidate() |
| 191 | 229 |
wdTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false, block: {_ in
|
@@ -43,6 +43,15 @@ class Measurements : ObservableObject {
|
||
| 43 | 43 |
points.filter { $0.isSample }
|
| 44 | 44 |
} |
| 45 | 45 |
|
| 46 |
+ func points(in range: ClosedRange<Date>) -> [Point] {
|
|
| 47 |
+ guard !points.isEmpty else { return [] }
|
|
| 48 |
+ |
|
| 49 |
+ let startIndex = indexOfFirstPoint(onOrAfter: range.lowerBound) |
|
| 50 |
+ let endIndex = indexOfFirstPoint(after: range.upperBound) |
|
| 51 |
+ guard startIndex < endIndex else { return [] }
|
|
| 52 |
+ return Array(points[startIndex..<endIndex]) |
|
| 53 |
+ } |
|
| 54 |
+ |
|
| 46 | 55 |
private func rebuildContext() {
|
| 47 | 56 |
context.reset() |
| 48 | 57 |
for point in points where point.isSample {
|
@@ -60,6 +69,7 @@ class Measurements : ObservableObject {
|
||
| 60 | 69 |
} |
| 61 | 70 |
|
| 62 | 71 |
func removeValue(index: Int) {
|
| 72 |
+ guard points.indices.contains(index) else { return }
|
|
| 63 | 73 |
points.remove(at: index) |
| 64 | 74 |
for index in points.indices {
|
| 65 | 75 |
points[index].id = index |
@@ -94,18 +104,100 @@ class Measurements : ObservableObject {
|
||
| 94 | 104 |
rebuildContext() |
| 95 | 105 |
self.objectWillChange.send() |
| 96 | 106 |
} |
| 107 |
+ |
|
| 108 |
+ func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
|
|
| 109 |
+ let originalSamples = samplePoints |
|
| 110 |
+ guard !originalSamples.isEmpty else { return }
|
|
| 111 |
+ |
|
| 112 |
+ var rebuiltPoints: [Point] = [] |
|
| 113 |
+ var lastKeptSampleIndex: Int? |
|
| 114 |
+ |
|
| 115 |
+ for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
|
|
| 116 |
+ if let lastKeptSampleIndex {
|
|
| 117 |
+ let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1 |
|
| 118 |
+ let previousSample = originalSamples[lastKeptSampleIndex] |
|
| 119 |
+ let originalHadDiscontinuityBetween = points.contains { point in
|
|
| 120 |
+ point.isDiscontinuity && |
|
| 121 |
+ point.timestamp > previousSample.timestamp && |
|
| 122 |
+ point.timestamp <= sample.timestamp |
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
|
|
| 126 |
+ rebuiltPoints.append( |
|
| 127 |
+ Point( |
|
| 128 |
+ id: rebuiltPoints.count, |
|
| 129 |
+ timestamp: sample.timestamp, |
|
| 130 |
+ value: rebuiltPoints.last?.value ?? sample.value, |
|
| 131 |
+ kind: .discontinuity |
|
| 132 |
+ ) |
|
| 133 |
+ ) |
|
| 134 |
+ } |
|
| 135 |
+ } |
|
| 136 |
+ |
|
| 137 |
+ rebuiltPoints.append( |
|
| 138 |
+ Point( |
|
| 139 |
+ id: rebuiltPoints.count, |
|
| 140 |
+ timestamp: sample.timestamp, |
|
| 141 |
+ value: sample.value, |
|
| 142 |
+ kind: .sample |
|
| 143 |
+ ) |
|
| 144 |
+ ) |
|
| 145 |
+ lastKeptSampleIndex = sampleIndex |
|
| 146 |
+ } |
|
| 147 |
+ |
|
| 148 |
+ points = rebuiltPoints |
|
| 149 |
+ rebuildContext() |
|
| 150 |
+ self.objectWillChange.send() |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 153 |
+ private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
|
|
| 154 |
+ var lowerBound = 0 |
|
| 155 |
+ var upperBound = points.count |
|
| 156 |
+ |
|
| 157 |
+ while lowerBound < upperBound {
|
|
| 158 |
+ let midIndex = (lowerBound + upperBound) / 2 |
|
| 159 |
+ if points[midIndex].timestamp < date {
|
|
| 160 |
+ lowerBound = midIndex + 1 |
|
| 161 |
+ } else {
|
|
| 162 |
+ upperBound = midIndex |
|
| 163 |
+ } |
|
| 164 |
+ } |
|
| 165 |
+ |
|
| 166 |
+ return lowerBound |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ private func indexOfFirstPoint(after date: Date) -> Int {
|
|
| 170 |
+ var lowerBound = 0 |
|
| 171 |
+ var upperBound = points.count |
|
| 172 |
+ |
|
| 173 |
+ while lowerBound < upperBound {
|
|
| 174 |
+ let midIndex = (lowerBound + upperBound) / 2 |
|
| 175 |
+ if points[midIndex].timestamp <= date {
|
|
| 176 |
+ lowerBound = midIndex + 1 |
|
| 177 |
+ } else {
|
|
| 178 |
+ upperBound = midIndex |
|
| 179 |
+ } |
|
| 180 |
+ } |
|
| 181 |
+ |
|
| 182 |
+ return lowerBound |
|
| 183 |
+ } |
|
| 97 | 184 |
} |
| 98 | 185 |
|
| 99 | 186 |
@Published var power = Measurement() |
| 100 | 187 |
@Published var voltage = Measurement() |
| 101 | 188 |
@Published var current = Measurement() |
| 102 | 189 |
@Published var temperature = Measurement() |
| 190 |
+ @Published var energy = Measurement() |
|
| 103 | 191 |
@Published var rssi = Measurement() |
| 104 | 192 |
|
| 105 | 193 |
let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250] |
| 106 | 194 |
|
| 107 | 195 |
private var pendingBucketSecond: Int? |
| 108 | 196 |
private var pendingBucketTimestamp: Date? |
| 197 |
+ private let energyResetEpsilon = 0.0005 |
|
| 198 |
+ private var lastEnergyCounterValue: Double? |
|
| 199 |
+ private var lastEnergyGroupID: UInt8? |
|
| 200 |
+ private var accumulatedEnergyValue: Double = 0 |
|
| 109 | 201 |
|
| 110 | 202 |
private var itemsInSum: Double = 0 |
| 111 | 203 |
private var powerSum: Double = 0 |
@@ -141,8 +233,12 @@ class Measurements : ObservableObject {
|
||
| 141 | 233 |
voltage.resetSeries() |
| 142 | 234 |
current.resetSeries() |
| 143 | 235 |
temperature.resetSeries() |
| 236 |
+ energy.resetSeries() |
|
| 144 | 237 |
rssi.resetSeries() |
| 145 | 238 |
resetPendingAggregation() |
| 239 |
+ lastEnergyCounterValue = nil |
|
| 240 |
+ lastEnergyGroupID = nil |
|
| 241 |
+ accumulatedEnergyValue = 0 |
|
| 146 | 242 |
self.objectWillChange.send() |
| 147 | 243 |
} |
| 148 | 244 |
|
@@ -155,7 +251,11 @@ class Measurements : ObservableObject {
|
||
| 155 | 251 |
voltage.removeValue(index: idx) |
| 156 | 252 |
current.removeValue(index: idx) |
| 157 | 253 |
temperature.removeValue(index: idx) |
| 254 |
+ energy.removeValue(index: idx) |
|
| 158 | 255 |
rssi.removeValue(index: idx) |
| 256 |
+ lastEnergyCounterValue = nil |
|
| 257 |
+ lastEnergyGroupID = nil |
|
| 258 |
+ accumulatedEnergyValue = 0 |
|
| 159 | 259 |
self.objectWillChange.send() |
| 160 | 260 |
} |
| 161 | 261 |
|
@@ -165,7 +265,39 @@ class Measurements : ObservableObject {
|
||
| 165 | 265 |
voltage.trim(before: cutoff) |
| 166 | 266 |
current.trim(before: cutoff) |
| 167 | 267 |
temperature.trim(before: cutoff) |
| 268 |
+ energy.trim(before: cutoff) |
|
| 168 | 269 |
rssi.trim(before: cutoff) |
| 270 |
+ lastEnergyCounterValue = nil |
|
| 271 |
+ lastEnergyGroupID = nil |
|
| 272 |
+ accumulatedEnergyValue = 0 |
|
| 273 |
+ self.objectWillChange.send() |
|
| 274 |
+ } |
|
| 275 |
+ |
|
| 276 |
+ func keepOnly(in range: ClosedRange<Date>) {
|
|
| 277 |
+ flushPendingValues() |
|
| 278 |
+ power.filterSamples { range.contains($0) }
|
|
| 279 |
+ voltage.filterSamples { range.contains($0) }
|
|
| 280 |
+ current.filterSamples { range.contains($0) }
|
|
| 281 |
+ temperature.filterSamples { range.contains($0) }
|
|
| 282 |
+ energy.filterSamples { range.contains($0) }
|
|
| 283 |
+ rssi.filterSamples { range.contains($0) }
|
|
| 284 |
+ lastEnergyCounterValue = nil |
|
| 285 |
+ lastEnergyGroupID = nil |
|
| 286 |
+ accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 |
|
| 287 |
+ self.objectWillChange.send() |
|
| 288 |
+ } |
|
| 289 |
+ |
|
| 290 |
+ func removeValues(in range: ClosedRange<Date>) {
|
|
| 291 |
+ flushPendingValues() |
|
| 292 |
+ power.filterSamples { !range.contains($0) }
|
|
| 293 |
+ voltage.filterSamples { !range.contains($0) }
|
|
| 294 |
+ current.filterSamples { !range.contains($0) }
|
|
| 295 |
+ temperature.filterSamples { !range.contains($0) }
|
|
| 296 |
+ energy.filterSamples { !range.contains($0) }
|
|
| 297 |
+ rssi.filterSamples { !range.contains($0) }
|
|
| 298 |
+ lastEnergyCounterValue = nil |
|
| 299 |
+ lastEnergyGroupID = nil |
|
| 300 |
+ accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0 |
|
| 169 | 301 |
self.objectWillChange.send() |
| 170 | 302 |
} |
| 171 | 303 |
|
@@ -201,10 +333,25 @@ class Measurements : ObservableObject {
|
||
| 201 | 333 |
voltage.addDiscontinuity(timestamp: timestamp) |
| 202 | 334 |
current.addDiscontinuity(timestamp: timestamp) |
| 203 | 335 |
temperature.addDiscontinuity(timestamp: timestamp) |
| 336 |
+ energy.addDiscontinuity(timestamp: timestamp) |
|
| 204 | 337 |
rssi.addDiscontinuity(timestamp: timestamp) |
| 205 | 338 |
self.objectWillChange.send() |
| 206 | 339 |
} |
| 207 | 340 |
|
| 341 |
+ func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
|
|
| 342 |
+ if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
|
|
| 343 |
+ let delta = value - lastEnergyCounterValue |
|
| 344 |
+ if delta > energyResetEpsilon {
|
|
| 345 |
+ accumulatedEnergyValue += delta |
|
| 346 |
+ } |
|
| 347 |
+ } |
|
| 348 |
+ |
|
| 349 |
+ energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue) |
|
| 350 |
+ lastEnergyCounterValue = value |
|
| 351 |
+ lastEnergyGroupID = groupID |
|
| 352 |
+ self.objectWillChange.send() |
|
| 353 |
+ } |
|
| 354 |
+ |
|
| 208 | 355 |
func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
|
| 209 | 356 |
if shouldFlushPendingValues {
|
| 210 | 357 |
flushPendingValues() |
@@ -594,6 +594,18 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 594 | 594 |
chargeRecordLastPower = 0 |
| 595 | 595 |
} |
| 596 | 596 |
|
| 597 |
+ private func currentEnergySample() -> (groupID: UInt8, value: Double)? {
|
|
| 598 |
+ guard showsDataGroupEnergy else { return nil }
|
|
| 599 |
+ |
|
| 600 |
+ if model == .TC66C && !hasObservedActiveDataGroup {
|
|
| 601 |
+ return nil |
|
| 602 |
+ } |
|
| 603 |
+ |
|
| 604 |
+ let groupID = selectedDataGroup |
|
| 605 |
+ guard let record = dataGroupRecords[Int(groupID)] else { return nil }
|
|
| 606 |
+ return (groupID, record.wh) |
|
| 607 |
+ } |
|
| 608 |
+ |
|
| 597 | 609 |
private func cancelPendingDataDumpRequest(reason: String) {
|
| 598 | 610 |
guard let pendingDataDumpWorkItem else { return }
|
| 599 | 611 |
track("\(name) - Cancel scheduled data request (\(reason))")
|
@@ -743,6 +755,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 743 | 755 |
} |
| 744 | 756 |
} |
| 745 | 757 |
updateChargeRecord(at: dataDumpRequestTimestamp) |
| 758 |
+ if let energySample = currentEnergySample() {
|
|
| 759 |
+ measurements.captureEnergyValue( |
|
| 760 |
+ timestamp: dataDumpRequestTimestamp, |
|
| 761 |
+ value: energySample.value, |
|
| 762 |
+ groupID: energySample.groupID |
|
| 763 |
+ ) |
|
| 764 |
+ } |
|
| 746 | 765 |
measurements.addValues( |
| 747 | 766 |
timestamp: dataDumpRequestTimestamp, |
| 748 | 767 |
power: power, |
@@ -14,8 +14,43 @@ private enum PresentTrackingMode: CaseIterable, Hashable {
|
||
| 14 | 14 |
} |
| 15 | 15 |
|
| 16 | 16 |
struct MeasurementChartView: View {
|
| 17 |
+ private enum SmoothingLevel: CaseIterable, Hashable {
|
|
| 18 |
+ case off |
|
| 19 |
+ case light |
|
| 20 |
+ case medium |
|
| 21 |
+ case strong |
|
| 22 |
+ |
|
| 23 |
+ var label: String {
|
|
| 24 |
+ switch self {
|
|
| 25 |
+ case .off: return "Off" |
|
| 26 |
+ case .light: return "Light" |
|
| 27 |
+ case .medium: return "Medium" |
|
| 28 |
+ case .strong: return "Strong" |
|
| 29 |
+ } |
|
| 30 |
+ } |
|
| 31 |
+ |
|
| 32 |
+ var shortLabel: String {
|
|
| 33 |
+ switch self {
|
|
| 34 |
+ case .off: return "Off" |
|
| 35 |
+ case .light: return "Low" |
|
| 36 |
+ case .medium: return "Med" |
|
| 37 |
+ case .strong: return "High" |
|
| 38 |
+ } |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ var movingAverageWindowSize: Int {
|
|
| 42 |
+ switch self {
|
|
| 43 |
+ case .off: return 1 |
|
| 44 |
+ case .light: return 5 |
|
| 45 |
+ case .medium: return 11 |
|
| 46 |
+ case .strong: return 21 |
|
| 47 |
+ } |
|
| 48 |
+ } |
|
| 49 |
+ } |
|
| 50 |
+ |
|
| 17 | 51 |
private enum SeriesKind {
|
| 18 | 52 |
case power |
| 53 |
+ case energy |
|
| 19 | 54 |
case voltage |
| 20 | 55 |
case current |
| 21 | 56 |
case temperature |
@@ -23,6 +58,7 @@ struct MeasurementChartView: View {
|
||
| 23 | 58 |
var unit: String {
|
| 24 | 59 |
switch self {
|
| 25 | 60 |
case .power: return "W" |
| 61 |
+ case .energy: return "Wh" |
|
| 26 | 62 |
case .voltage: return "V" |
| 27 | 63 |
case .current: return "A" |
| 28 | 64 |
case .temperature: return "" |
@@ -32,6 +68,7 @@ struct MeasurementChartView: View {
|
||
| 32 | 68 |
var tint: Color {
|
| 33 | 69 |
switch self {
|
| 34 | 70 |
case .power: return .red |
| 71 |
+ case .energy: return .teal |
|
| 35 | 72 |
case .voltage: return .green |
| 36 | 73 |
case .current: return .blue |
| 37 | 74 |
case .temperature: return .orange |
@@ -53,8 +90,10 @@ struct MeasurementChartView: View {
|
||
| 53 | 90 |
private let minimumVoltageSpan = 0.5 |
| 54 | 91 |
private let minimumCurrentSpan = 0.5 |
| 55 | 92 |
private let minimumPowerSpan = 0.5 |
| 93 |
+ private let minimumEnergySpan = 0.1 |
|
| 56 | 94 |
private let minimumTemperatureSpan = 1.0 |
| 57 | 95 |
private let defaultEmptyChartTimeSpan: TimeInterval = 60 |
| 96 |
+ private let selectorTint: Color = .blue |
|
| 58 | 97 |
|
| 59 | 98 |
let compactLayout: Bool |
| 60 | 99 |
let availableSize: CGSize |
@@ -67,8 +106,9 @@ struct MeasurementChartView: View {
|
||
| 67 | 106 |
@State var displayVoltage: Bool = false |
| 68 | 107 |
@State var displayCurrent: Bool = false |
| 69 | 108 |
@State var displayPower: Bool = true |
| 109 |
+ @State var displayEnergy: Bool = false |
|
| 70 | 110 |
@State var displayTemperature: Bool = false |
| 71 |
- @State private var showResetConfirmation: Bool = false |
|
| 111 |
+ @State private var smoothingLevel: SmoothingLevel = .off |
|
| 72 | 112 |
@State private var chartNow: Date = Date() |
| 73 | 113 |
@State private var selectedVisibleTimeRange: ClosedRange<Date>? |
| 74 | 114 |
@State private var isPinnedToPresent: Bool = false |
@@ -78,6 +118,7 @@ struct MeasurementChartView: View {
|
||
| 78 | 118 |
@State private var sharedAxisOrigin: Double = 0 |
| 79 | 119 |
@State private var sharedAxisUpperBound: Double = 1 |
| 80 | 120 |
@State private var powerAxisOrigin: Double = 0 |
| 121 |
+ @State private var energyAxisOrigin: Double = 0 |
|
| 81 | 122 |
@State private var voltageAxisOrigin: Double = 0 |
| 82 | 123 |
@State private var currentAxisOrigin: Double = 0 |
| 83 | 124 |
@State private var temperatureAxisOrigin: Double = 0 |
@@ -201,6 +242,12 @@ struct MeasurementChartView: View {
|
||
| 201 | 242 |
minimumYSpan: minimumPowerSpan, |
| 202 | 243 |
visibleTimeRange: visibleTimeRange |
| 203 | 244 |
) |
| 245 |
+ let energySeries = series( |
|
| 246 |
+ for: measurements.energy, |
|
| 247 |
+ kind: .energy, |
|
| 248 |
+ minimumYSpan: minimumEnergySpan, |
|
| 249 |
+ visibleTimeRange: visibleTimeRange |
|
| 250 |
+ ) |
|
| 204 | 251 |
let voltageSeries = series( |
| 205 | 252 |
for: measurements.voltage, |
| 206 | 253 |
kind: .voltage, |
@@ -221,6 +268,7 @@ struct MeasurementChartView: View {
|
||
| 221 | 268 |
) |
| 222 | 269 |
let primarySeries = displayedPrimarySeries( |
| 223 | 270 |
powerSeries: powerSeries, |
| 271 |
+ energySeries: energySeries, |
|
| 224 | 272 |
voltageSeries: voltageSeries, |
| 225 | 273 |
currentSeries: currentSeries |
| 226 | 274 |
) |
@@ -239,6 +287,7 @@ struct MeasurementChartView: View {
|
||
| 239 | 287 |
primaryAxisView( |
| 240 | 288 |
height: plotHeight, |
| 241 | 289 |
powerSeries: powerSeries, |
| 290 |
+ energySeries: energySeries, |
|
| 242 | 291 |
voltageSeries: voltageSeries, |
| 243 | 292 |
currentSeries: currentSeries |
| 244 | 293 |
) |
@@ -256,6 +305,7 @@ struct MeasurementChartView: View {
|
||
| 256 | 305 |
discontinuityMarkers(points: primarySeries.points, context: primarySeries.context) |
| 257 | 306 |
renderedChart( |
| 258 | 307 |
powerSeries: powerSeries, |
| 308 |
+ energySeries: energySeries, |
|
| 259 | 309 |
voltageSeries: voltageSeries, |
| 260 | 310 |
currentSeries: currentSeries, |
| 261 | 311 |
temperatureSeries: temperatureSeries |
@@ -268,6 +318,7 @@ struct MeasurementChartView: View {
|
||
| 268 | 318 |
secondaryAxisView( |
| 269 | 319 |
height: plotHeight, |
| 270 | 320 |
powerSeries: powerSeries, |
| 321 |
+ energySeries: energySeries, |
|
| 271 | 322 |
voltageSeries: voltageSeries, |
| 272 | 323 |
currentSeries: currentSeries, |
| 273 | 324 |
temperatureSeries: temperatureSeries |
@@ -322,9 +373,12 @@ struct MeasurementChartView: View {
|
||
| 322 | 373 |
points: selectorSeries.points, |
| 323 | 374 |
context: selectorSeries.context, |
| 324 | 375 |
availableTimeRange: availableTimeRange, |
| 325 |
- accentColor: selectorSeries.kind.tint, |
|
| 376 |
+ selectorTint: selectorTint, |
|
| 326 | 377 |
compactLayout: compactLayout, |
| 327 | 378 |
minimumSelectionSpan: minimumTimeSpan, |
| 379 |
+ onKeepSelection: trimBufferToSelection, |
|
| 380 |
+ onRemoveSelection: removeSelectionFromBuffer, |
|
| 381 |
+ onResetBuffer: resetBuffer, |
|
| 328 | 382 |
selectedTimeRange: $selectedVisibleTimeRange, |
| 329 | 383 |
isPinnedToPresent: $isPinnedToPresent, |
| 330 | 384 |
presentTrackingMode: $presentTrackingMode |
@@ -355,8 +409,9 @@ struct MeasurementChartView: View {
|
||
| 355 | 409 |
let condensedLayout = compactLayout || verticalSizeClass == .compact |
| 356 | 410 |
let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10) |
| 357 | 411 |
|
| 358 |
- let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
|
|
| 412 |
+ let controlsPanel = VStack(alignment: .leading, spacing: sectionSpacing) {
|
|
| 359 | 413 |
seriesToggleRow(condensedLayout: condensedLayout) |
| 414 |
+ smoothingControlsRow(condensedLayout: condensedLayout) |
|
| 360 | 415 |
} |
| 361 | 416 |
.padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12)) |
| 362 | 417 |
.padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)) |
@@ -371,18 +426,10 @@ struct MeasurementChartView: View {
|
||
| 371 | 426 |
|
| 372 | 427 |
return Group {
|
| 373 | 428 |
if stackedToolbarLayout {
|
| 374 |
- VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
|
|
| 375 |
- controlsPanel |
|
| 376 |
- HStack {
|
|
| 377 |
- Spacer(minLength: 0) |
|
| 378 |
- resetBufferButton(condensedLayout: condensedLayout) |
|
| 379 |
- } |
|
| 380 |
- } |
|
| 429 |
+ controlsPanel |
|
| 381 | 430 |
} else {
|
| 382 | 431 |
HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
|
| 383 | 432 |
controlsPanel |
| 384 |
- Spacer(minLength: 0) |
|
| 385 |
- resetBufferButton(condensedLayout: condensedLayout) |
|
| 386 | 433 |
} |
| 387 | 434 |
} |
| 388 | 435 |
} |
@@ -436,6 +483,7 @@ struct MeasurementChartView: View {
|
||
| 436 | 483 |
displayVoltage.toggle() |
| 437 | 484 |
if displayVoltage {
|
| 438 | 485 |
displayPower = false |
| 486 |
+ displayEnergy = false |
|
| 439 | 487 |
if displayTemperature && displayCurrent {
|
| 440 | 488 |
displayCurrent = false |
| 441 | 489 |
} |
@@ -446,6 +494,7 @@ struct MeasurementChartView: View {
|
||
| 446 | 494 |
displayCurrent.toggle() |
| 447 | 495 |
if displayCurrent {
|
| 448 | 496 |
displayPower = false |
| 497 |
+ displayEnergy = false |
|
| 449 | 498 |
if displayTemperature && displayVoltage {
|
| 450 | 499 |
displayVoltage = false |
| 451 | 500 |
} |
@@ -455,6 +504,16 @@ struct MeasurementChartView: View {
|
||
| 455 | 504 |
seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
|
| 456 | 505 |
displayPower.toggle() |
| 457 | 506 |
if displayPower {
|
| 507 |
+ displayEnergy = false |
|
| 508 |
+ displayCurrent = false |
|
| 509 |
+ displayVoltage = false |
|
| 510 |
+ } |
|
| 511 |
+ } |
|
| 512 |
+ |
|
| 513 |
+ seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
|
|
| 514 |
+ displayEnergy.toggle() |
|
| 515 |
+ if displayEnergy {
|
|
| 516 |
+ displayPower = false |
|
| 458 | 517 |
displayCurrent = false |
| 459 | 518 |
displayVoltage = false |
| 460 | 519 |
} |
@@ -525,6 +584,64 @@ struct MeasurementChartView: View {
|
||
| 525 | 584 |
} |
| 526 | 585 |
} |
| 527 | 586 |
|
| 587 |
+ private func smoothingControlsRow(condensedLayout: Bool) -> some View {
|
|
| 588 |
+ HStack(spacing: condensedLayout ? 8 : 10) {
|
|
| 589 |
+ Text("Smoothing")
|
|
| 590 |
+ .font((condensedLayout ? Font.caption : .footnote).weight(.semibold)) |
|
| 591 |
+ .foregroundColor(.secondary) |
|
| 592 |
+ |
|
| 593 |
+ Menu {
|
|
| 594 |
+ ForEach(SmoothingLevel.allCases, id: \.self) { level in
|
|
| 595 |
+ Button {
|
|
| 596 |
+ smoothingLevel = level |
|
| 597 |
+ } label: {
|
|
| 598 |
+ if smoothingLevel == level {
|
|
| 599 |
+ Label(level.label, systemImage: "checkmark") |
|
| 600 |
+ } else {
|
|
| 601 |
+ Text(level.label) |
|
| 602 |
+ } |
|
| 603 |
+ } |
|
| 604 |
+ } |
|
| 605 |
+ } label: {
|
|
| 606 |
+ Label( |
|
| 607 |
+ condensedLayout ? smoothingLevel.shortLabel : smoothingLevel.label, |
|
| 608 |
+ systemImage: "waveform.path" |
|
| 609 |
+ ) |
|
| 610 |
+ .font(controlChipFont(condensedLayout: condensedLayout)) |
|
| 611 |
+ .foregroundColor(smoothingLevel == .off ? .primary : .blue) |
|
| 612 |
+ .padding(.horizontal, condensedLayout ? 10 : 12) |
|
| 613 |
+ .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 9 : 8)) |
|
| 614 |
+ .background( |
|
| 615 |
+ Capsule(style: .continuous) |
|
| 616 |
+ .fill( |
|
| 617 |
+ smoothingLevel == .off |
|
| 618 |
+ ? Color.secondary.opacity(0.10) |
|
| 619 |
+ : Color.blue.opacity(0.12) |
|
| 620 |
+ ) |
|
| 621 |
+ ) |
|
| 622 |
+ .overlay( |
|
| 623 |
+ Capsule(style: .continuous) |
|
| 624 |
+ .stroke( |
|
| 625 |
+ smoothingLevel == .off |
|
| 626 |
+ ? Color.secondary.opacity(0.18) |
|
| 627 |
+ : Color.blue.opacity(0.28), |
|
| 628 |
+ lineWidth: 1 |
|
| 629 |
+ ) |
|
| 630 |
+ ) |
|
| 631 |
+ } |
|
| 632 |
+ .fixedSize(horizontal: true, vertical: false) |
|
| 633 |
+ |
|
| 634 |
+ if smoothingLevel != .off {
|
|
| 635 |
+ Text("MA \(smoothingLevel.movingAverageWindowSize)")
|
|
| 636 |
+ .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold)) |
|
| 637 |
+ .foregroundColor(.secondary) |
|
| 638 |
+ .monospacedDigit() |
|
| 639 |
+ } |
|
| 640 |
+ |
|
| 641 |
+ Spacer(minLength: 0) |
|
| 642 |
+ } |
|
| 643 |
+ } |
|
| 644 |
+ |
|
| 528 | 645 |
private func seriesToggleButton( |
| 529 | 646 |
title: String, |
| 530 | 647 |
isOn: Bool, |
@@ -592,28 +709,8 @@ struct MeasurementChartView: View {
|
||
| 592 | 709 |
.accessibilityLabel(accessibilityLabel) |
| 593 | 710 |
} |
| 594 | 711 |
|
| 595 |
- private func resetBufferButton(condensedLayout: Bool) -> some View {
|
|
| 596 |
- Button(action: {
|
|
| 597 |
- showResetConfirmation = true |
|
| 598 |
- }) {
|
|
| 599 |
- Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash") |
|
| 600 |
- .font(controlChipFont(condensedLayout: condensedLayout)) |
|
| 601 |
- .padding(.horizontal, condensedLayout ? 14 : 16) |
|
| 602 |
- .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11)) |
|
| 603 |
- } |
|
| 604 |
- .buttonStyle(.plain) |
|
| 605 |
- .foregroundColor(.white) |
|
| 606 |
- .background( |
|
| 607 |
- Capsule(style: .continuous) |
|
| 608 |
- .fill(Color.red.opacity(0.8)) |
|
| 609 |
- ) |
|
| 610 |
- .fixedSize(horizontal: true, vertical: false) |
|
| 611 |
- .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
|
| 612 |
- Button("Reset series", role: .destructive) {
|
|
| 613 |
- measurements.resetSeries() |
|
| 614 |
- } |
|
| 615 |
- Button("Cancel", role: .cancel) {}
|
|
| 616 |
- } |
|
| 712 |
+ private func resetBuffer() {
|
|
| 713 |
+ measurements.resetSeries() |
|
| 617 | 714 |
} |
| 618 | 715 |
|
| 619 | 716 |
private func seriesToggleFont(condensedLayout: Bool) -> Font {
|
@@ -634,6 +731,7 @@ struct MeasurementChartView: View {
|
||
| 634 | 731 |
private func primaryAxisView( |
| 635 | 732 |
height: CGFloat, |
| 636 | 733 |
powerSeries: SeriesData, |
| 734 |
+ energySeries: SeriesData, |
|
| 637 | 735 |
voltageSeries: SeriesData, |
| 638 | 736 |
currentSeries: SeriesData |
| 639 | 737 |
) -> some View {
|
@@ -645,6 +743,14 @@ struct MeasurementChartView: View {
|
||
| 645 | 743 |
measurementUnit: powerSeries.kind.unit, |
| 646 | 744 |
tint: powerSeries.kind.tint |
| 647 | 745 |
) |
| 746 |
+ } else if displayEnergy {
|
|
| 747 |
+ yAxisLabelsView( |
|
| 748 |
+ height: height, |
|
| 749 |
+ context: energySeries.context, |
|
| 750 |
+ seriesKind: .energy, |
|
| 751 |
+ measurementUnit: energySeries.kind.unit, |
|
| 752 |
+ tint: energySeries.kind.tint |
|
| 753 |
+ ) |
|
| 648 | 754 |
} else if displayVoltage {
|
| 649 | 755 |
yAxisLabelsView( |
| 650 | 756 |
height: height, |
@@ -667,6 +773,7 @@ struct MeasurementChartView: View {
|
||
| 667 | 773 |
@ViewBuilder |
| 668 | 774 |
private func renderedChart( |
| 669 | 775 |
powerSeries: SeriesData, |
| 776 |
+ energySeries: SeriesData, |
|
| 670 | 777 |
voltageSeries: SeriesData, |
| 671 | 778 |
currentSeries: SeriesData, |
| 672 | 779 |
temperatureSeries: SeriesData |
@@ -674,6 +781,9 @@ struct MeasurementChartView: View {
|
||
| 674 | 781 |
if self.displayPower {
|
| 675 | 782 |
Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red) |
| 676 | 783 |
.opacity(0.72) |
| 784 |
+ } else if self.displayEnergy {
|
|
| 785 |
+ Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal) |
|
| 786 |
+ .opacity(0.78) |
|
| 677 | 787 |
} else {
|
| 678 | 788 |
if self.displayVoltage {
|
| 679 | 789 |
Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green) |
@@ -695,6 +805,7 @@ struct MeasurementChartView: View {
|
||
| 695 | 805 |
private func secondaryAxisView( |
| 696 | 806 |
height: CGFloat, |
| 697 | 807 |
powerSeries: SeriesData, |
| 808 |
+ energySeries: SeriesData, |
|
| 698 | 809 |
voltageSeries: SeriesData, |
| 699 | 810 |
currentSeries: SeriesData, |
| 700 | 811 |
temperatureSeries: SeriesData |
@@ -719,6 +830,7 @@ struct MeasurementChartView: View {
|
||
| 719 | 830 |
primaryAxisView( |
| 720 | 831 |
height: height, |
| 721 | 832 |
powerSeries: powerSeries, |
| 833 |
+ energySeries: energySeries, |
|
| 722 | 834 |
voltageSeries: voltageSeries, |
| 723 | 835 |
currentSeries: currentSeries |
| 724 | 836 |
) |
@@ -727,12 +839,16 @@ struct MeasurementChartView: View {
|
||
| 727 | 839 |
|
| 728 | 840 |
private func displayedPrimarySeries( |
| 729 | 841 |
powerSeries: SeriesData, |
| 842 |
+ energySeries: SeriesData, |
|
| 730 | 843 |
voltageSeries: SeriesData, |
| 731 | 844 |
currentSeries: SeriesData |
| 732 | 845 |
) -> SeriesData? {
|
| 733 | 846 |
if displayPower {
|
| 734 | 847 |
return powerSeries |
| 735 | 848 |
} |
| 849 |
+ if displayEnergy {
|
|
| 850 |
+ return energySeries |
|
| 851 |
+ } |
|
| 736 | 852 |
if displayVoltage {
|
| 737 | 853 |
return voltageSeries |
| 738 | 854 |
} |
@@ -748,10 +864,11 @@ struct MeasurementChartView: View {
|
||
| 748 | 864 |
minimumYSpan: Double, |
| 749 | 865 |
visibleTimeRange: ClosedRange<Date>? = nil |
| 750 | 866 |
) -> SeriesData {
|
| 751 |
- let points = filteredPoints( |
|
| 867 |
+ let rawPoints = filteredPoints( |
|
| 752 | 868 |
measurement, |
| 753 | 869 |
visibleTimeRange: visibleTimeRange |
| 754 | 870 |
) |
| 871 |
+ let points = smoothedPoints(from: rawPoints) |
|
| 755 | 872 |
let samplePoints = points.filter { $0.isSample }
|
| 756 | 873 |
let context = ChartContext() |
| 757 | 874 |
|
@@ -801,10 +918,89 @@ struct MeasurementChartView: View {
|
||
| 801 | 918 |
) |
| 802 | 919 |
} |
| 803 | 920 |
|
| 921 |
+ private func smoothedPoints( |
|
| 922 |
+ from points: [Measurements.Measurement.Point] |
|
| 923 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 924 |
+ guard smoothingLevel != .off else { return points }
|
|
| 925 |
+ |
|
| 926 |
+ var smoothedPoints: [Measurements.Measurement.Point] = [] |
|
| 927 |
+ var currentSegment: [Measurements.Measurement.Point] = [] |
|
| 928 |
+ |
|
| 929 |
+ func flushCurrentSegment() {
|
|
| 930 |
+ guard !currentSegment.isEmpty else { return }
|
|
| 931 |
+ |
|
| 932 |
+ for point in smoothedSegment(currentSegment) {
|
|
| 933 |
+ smoothedPoints.append( |
|
| 934 |
+ Measurements.Measurement.Point( |
|
| 935 |
+ id: smoothedPoints.count, |
|
| 936 |
+ timestamp: point.timestamp, |
|
| 937 |
+ value: point.value, |
|
| 938 |
+ kind: .sample |
|
| 939 |
+ ) |
|
| 940 |
+ ) |
|
| 941 |
+ } |
|
| 942 |
+ |
|
| 943 |
+ currentSegment.removeAll(keepingCapacity: true) |
|
| 944 |
+ } |
|
| 945 |
+ |
|
| 946 |
+ for point in points {
|
|
| 947 |
+ if point.isDiscontinuity {
|
|
| 948 |
+ flushCurrentSegment() |
|
| 949 |
+ |
|
| 950 |
+ if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
|
|
| 951 |
+ smoothedPoints.append( |
|
| 952 |
+ Measurements.Measurement.Point( |
|
| 953 |
+ id: smoothedPoints.count, |
|
| 954 |
+ timestamp: point.timestamp, |
|
| 955 |
+ value: smoothedPoints.last?.value ?? point.value, |
|
| 956 |
+ kind: .discontinuity |
|
| 957 |
+ ) |
|
| 958 |
+ ) |
|
| 959 |
+ } |
|
| 960 |
+ } else {
|
|
| 961 |
+ currentSegment.append(point) |
|
| 962 |
+ } |
|
| 963 |
+ } |
|
| 964 |
+ |
|
| 965 |
+ flushCurrentSegment() |
|
| 966 |
+ return smoothedPoints |
|
| 967 |
+ } |
|
| 968 |
+ |
|
| 969 |
+ private func smoothedSegment( |
|
| 970 |
+ _ segment: [Measurements.Measurement.Point] |
|
| 971 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 972 |
+ let windowSize = smoothingLevel.movingAverageWindowSize |
|
| 973 |
+ guard windowSize > 1, segment.count > 2 else { return segment }
|
|
| 974 |
+ |
|
| 975 |
+ let radius = windowSize / 2 |
|
| 976 |
+ var prefixSums: [Double] = [0] |
|
| 977 |
+ prefixSums.reserveCapacity(segment.count + 1) |
|
| 978 |
+ |
|
| 979 |
+ for point in segment {
|
|
| 980 |
+ prefixSums.append(prefixSums[prefixSums.count - 1] + point.value) |
|
| 981 |
+ } |
|
| 982 |
+ |
|
| 983 |
+ return segment.enumerated().map { index, point in
|
|
| 984 |
+ let lowerBound = max(0, index - radius) |
|
| 985 |
+ let upperBound = min(segment.count - 1, index + radius) |
|
| 986 |
+ let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound] |
|
| 987 |
+ let average = sum / Double(upperBound - lowerBound + 1) |
|
| 988 |
+ |
|
| 989 |
+ return Measurements.Measurement.Point( |
|
| 990 |
+ id: point.id, |
|
| 991 |
+ timestamp: point.timestamp, |
|
| 992 |
+ value: average, |
|
| 993 |
+ kind: .sample |
|
| 994 |
+ ) |
|
| 995 |
+ } |
|
| 996 |
+ } |
|
| 997 |
+ |
|
| 804 | 998 |
private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
|
| 805 | 999 |
switch kind {
|
| 806 | 1000 |
case .power: |
| 807 | 1001 |
return measurements.power |
| 1002 |
+ case .energy: |
|
| 1003 |
+ return measurements.energy |
|
| 808 | 1004 |
case .voltage: |
| 809 | 1005 |
return measurements.voltage |
| 810 | 1006 |
case .current: |
@@ -818,6 +1014,8 @@ struct MeasurementChartView: View {
|
||
| 818 | 1014 |
switch kind {
|
| 819 | 1015 |
case .power: |
| 820 | 1016 |
return minimumPowerSpan |
| 1017 |
+ case .energy: |
|
| 1018 |
+ return minimumEnergySpan |
|
| 821 | 1019 |
case .voltage: |
| 822 | 1020 |
return minimumVoltageSpan |
| 823 | 1021 |
case .current: |
@@ -828,7 +1026,7 @@ struct MeasurementChartView: View {
|
||
| 828 | 1026 |
} |
| 829 | 1027 |
|
| 830 | 1028 |
private var supportsSharedOrigin: Bool {
|
| 831 |
- displayVoltage && displayCurrent && !displayPower |
|
| 1029 |
+ displayVoltage && displayCurrent && !displayPower && !displayEnergy |
|
| 832 | 1030 |
} |
| 833 | 1031 |
|
| 834 | 1032 |
private var minimumSharedScaleSpan: Double {
|
@@ -844,6 +1042,10 @@ struct MeasurementChartView: View {
|
||
| 844 | 1042 |
return pinOrigin && powerAxisOrigin == 0 |
| 845 | 1043 |
} |
| 846 | 1044 |
|
| 1045 |
+ if displayEnergy {
|
|
| 1046 |
+ return pinOrigin && energyAxisOrigin == 0 |
|
| 1047 |
+ } |
|
| 1048 |
+ |
|
| 847 | 1049 |
let visibleOrigins = [ |
| 848 | 1050 |
displayVoltage ? voltageAxisOrigin : nil, |
| 849 | 1051 |
displayCurrent ? currentAxisOrigin : nil |
@@ -904,6 +1106,9 @@ struct MeasurementChartView: View {
|
||
| 904 | 1106 |
if displayPower {
|
| 905 | 1107 |
powerAxisOrigin = 0 |
| 906 | 1108 |
} |
| 1109 |
+ if displayEnergy {
|
|
| 1110 |
+ energyAxisOrigin = 0 |
|
| 1111 |
+ } |
|
| 907 | 1112 |
if displayVoltage {
|
| 908 | 1113 |
voltageAxisOrigin = 0 |
| 909 | 1114 |
} |
@@ -923,6 +1128,7 @@ struct MeasurementChartView: View {
|
||
| 923 | 1128 |
currentSeries: SeriesData |
| 924 | 1129 |
) {
|
| 925 | 1130 |
powerAxisOrigin = displayedLowerBoundForSeries(.power) |
| 1131 |
+ energyAxisOrigin = displayedLowerBoundForSeries(.energy) |
|
| 926 | 1132 |
voltageAxisOrigin = voltageSeries.autoLowerBound |
| 927 | 1133 |
currentAxisOrigin = currentSeries.autoLowerBound |
| 928 | 1134 |
temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature) |
@@ -945,6 +1151,16 @@ struct MeasurementChartView: View {
|
||
| 945 | 1151 |
), |
| 946 | 1152 |
minimumYSpan: minimumPowerSpan |
| 947 | 1153 |
).lowerBound |
| 1154 |
+ case .energy: |
|
| 1155 |
+ return pinOrigin |
|
| 1156 |
+ ? energyAxisOrigin |
|
| 1157 |
+ : automaticYBounds( |
|
| 1158 |
+ for: filteredSamplePoints( |
|
| 1159 |
+ measurements.energy, |
|
| 1160 |
+ visibleTimeRange: visibleTimeRange |
|
| 1161 |
+ ), |
|
| 1162 |
+ minimumYSpan: minimumEnergySpan |
|
| 1163 |
+ ).lowerBound |
|
| 948 | 1164 |
case .voltage: |
| 949 | 1165 |
if pinOrigin && useSharedOrigin && supportsSharedOrigin {
|
| 950 | 1166 |
return sharedAxisOrigin |
@@ -992,10 +1208,26 @@ struct MeasurementChartView: View {
|
||
| 992 | 1208 |
_ measurement: Measurements.Measurement, |
| 993 | 1209 |
visibleTimeRange: ClosedRange<Date>? = nil |
| 994 | 1210 |
) -> [Measurements.Measurement.Point] {
|
| 995 |
- measurement.points.filter { point in
|
|
| 996 |
- guard timeRange?.contains(point.timestamp) ?? true else { return false }
|
|
| 997 |
- return visibleTimeRange?.contains(point.timestamp) ?? true |
|
| 1211 |
+ let resolvedRange: ClosedRange<Date>? |
|
| 1212 |
+ |
|
| 1213 |
+ switch (timeRange, visibleTimeRange) {
|
|
| 1214 |
+ case let (baseRange?, visibleRange?): |
|
| 1215 |
+ let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound) |
|
| 1216 |
+ let upperBound = min(baseRange.upperBound, visibleRange.upperBound) |
|
| 1217 |
+ resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil |
|
| 1218 |
+ case let (baseRange?, nil): |
|
| 1219 |
+ resolvedRange = baseRange |
|
| 1220 |
+ case let (nil, visibleRange?): |
|
| 1221 |
+ resolvedRange = visibleRange |
|
| 1222 |
+ case (nil, nil): |
|
| 1223 |
+ resolvedRange = nil |
|
| 998 | 1224 |
} |
| 1225 |
+ |
|
| 1226 |
+ guard let resolvedRange else {
|
|
| 1227 |
+ return timeRange == nil && visibleTimeRange == nil ? measurement.points : [] |
|
| 1228 |
+ } |
|
| 1229 |
+ |
|
| 1230 |
+ return measurement.points(in: resolvedRange) |
|
| 999 | 1231 |
} |
| 1000 | 1232 |
|
| 1001 | 1233 |
private func filteredSamplePoints( |
@@ -1042,6 +1274,7 @@ struct MeasurementChartView: View {
|
||
| 1042 | 1274 |
private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
|
| 1043 | 1275 |
let candidates = [ |
| 1044 | 1276 |
filteredSamplePoints(measurements.power), |
| 1277 |
+ filteredSamplePoints(measurements.energy), |
|
| 1045 | 1278 |
filteredSamplePoints(measurements.voltage), |
| 1046 | 1279 |
filteredSamplePoints(measurements.current), |
| 1047 | 1280 |
filteredSamplePoints(measurements.temperature) |
@@ -1183,6 +1416,8 @@ struct MeasurementChartView: View {
|
||
| 1183 | 1416 |
switch kind {
|
| 1184 | 1417 |
case .power: |
| 1185 | 1418 |
return powerAxisOrigin |
| 1419 |
+ case .energy: |
|
| 1420 |
+ return energyAxisOrigin |
|
| 1186 | 1421 |
case .voltage: |
| 1187 | 1422 |
return voltageAxisOrigin |
| 1188 | 1423 |
case .current: |
@@ -1231,6 +1466,8 @@ struct MeasurementChartView: View {
|
||
| 1231 | 1466 |
switch kind {
|
| 1232 | 1467 |
case .power: |
| 1233 | 1468 |
powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power)) |
| 1469 |
+ case .energy: |
|
| 1470 |
+ energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy)) |
|
| 1234 | 1471 |
case .voltage: |
| 1235 | 1472 |
voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage)) |
| 1236 | 1473 |
case .current: |
@@ -1255,6 +1492,8 @@ struct MeasurementChartView: View {
|
||
| 1255 | 1492 |
switch kind {
|
| 1256 | 1493 |
case .power: |
| 1257 | 1494 |
powerAxisOrigin = 0 |
| 1495 |
+ case .energy: |
|
| 1496 |
+ energyAxisOrigin = 0 |
|
| 1258 | 1497 |
case .voltage: |
| 1259 | 1498 |
voltageAxisOrigin = 0 |
| 1260 | 1499 |
case .current: |
@@ -1291,6 +1530,13 @@ struct MeasurementChartView: View {
|
||
| 1291 | 1530 |
visibleTimeRange: visibleTimeRange |
| 1292 | 1531 |
).map(\.value).min() ?? 0 |
| 1293 | 1532 |
) |
| 1533 |
+ case .energy: |
|
| 1534 |
+ return snappedOriginValue( |
|
| 1535 |
+ filteredSamplePoints( |
|
| 1536 |
+ measurements.energy, |
|
| 1537 |
+ visibleTimeRange: visibleTimeRange |
|
| 1538 |
+ ).map(\.value).min() ?? 0 |
|
| 1539 |
+ ) |
|
| 1294 | 1540 |
case .voltage: |
| 1295 | 1541 |
return snappedOriginValue( |
| 1296 | 1542 |
filteredSamplePoints( |
@@ -1355,6 +1601,18 @@ struct MeasurementChartView: View {
|
||
| 1355 | 1601 |
return value.rounded(.up) |
| 1356 | 1602 |
} |
| 1357 | 1603 |
|
| 1604 |
+ private func trimBufferToSelection(_ range: ClosedRange<Date>) {
|
|
| 1605 |
+ measurements.keepOnly(in: range) |
|
| 1606 |
+ selectedVisibleTimeRange = nil |
|
| 1607 |
+ isPinnedToPresent = false |
|
| 1608 |
+ } |
|
| 1609 |
+ |
|
| 1610 |
+ private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
|
|
| 1611 |
+ measurements.removeValues(in: range) |
|
| 1612 |
+ selectedVisibleTimeRange = nil |
|
| 1613 |
+ isPinnedToPresent = false |
|
| 1614 |
+ } |
|
| 1615 |
+ |
|
| 1358 | 1616 |
private func yGuidePosition( |
| 1359 | 1617 |
for labelIndex: Int, |
| 1360 | 1618 |
context: ChartContext, |
@@ -1588,6 +1846,12 @@ private struct TimeRangeSelectorView: View {
|
||
| 1588 | 1846 |
case window |
| 1589 | 1847 |
} |
| 1590 | 1848 |
|
| 1849 |
+ private enum ActionTone {
|
|
| 1850 |
+ case reversible |
|
| 1851 |
+ case destructive |
|
| 1852 |
+ case destructiveProminent |
|
| 1853 |
+ } |
|
| 1854 |
+ |
|
| 1591 | 1855 |
private struct DragState {
|
| 1592 | 1856 |
let target: DragTarget |
| 1593 | 1857 |
let initialRange: ClosedRange<Date> |
@@ -1596,14 +1860,18 @@ private struct TimeRangeSelectorView: View {
|
||
| 1596 | 1860 |
let points: [Measurements.Measurement.Point] |
| 1597 | 1861 |
let context: ChartContext |
| 1598 | 1862 |
let availableTimeRange: ClosedRange<Date> |
| 1599 |
- let accentColor: Color |
|
| 1863 |
+ let selectorTint: Color |
|
| 1600 | 1864 |
let compactLayout: Bool |
| 1601 | 1865 |
let minimumSelectionSpan: TimeInterval |
| 1866 |
+ let onKeepSelection: (ClosedRange<Date>) -> Void |
|
| 1867 |
+ let onRemoveSelection: (ClosedRange<Date>) -> Void |
|
| 1868 |
+ let onResetBuffer: () -> Void |
|
| 1602 | 1869 |
|
| 1603 | 1870 |
@Binding var selectedTimeRange: ClosedRange<Date>? |
| 1604 | 1871 |
@Binding var isPinnedToPresent: Bool |
| 1605 | 1872 |
@Binding var presentTrackingMode: PresentTrackingMode |
| 1606 | 1873 |
@State private var dragState: DragState? |
| 1874 |
+ @State private var showResetConfirmation: Bool = false |
|
| 1607 | 1875 |
|
| 1608 | 1876 |
private var totalSpan: TimeInterval {
|
| 1609 | 1877 |
availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound) |
@@ -1621,10 +1889,6 @@ private struct TimeRangeSelectorView: View {
|
||
| 1621 | 1889 |
compactLayout ? 14 : 16 |
| 1622 | 1890 |
} |
| 1623 | 1891 |
|
| 1624 |
- private var summaryFont: Font {
|
|
| 1625 |
- compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold) |
|
| 1626 |
- } |
|
| 1627 |
- |
|
| 1628 | 1892 |
private var boundaryFont: Font {
|
| 1629 | 1893 |
compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold) |
| 1630 | 1894 |
} |
@@ -1661,6 +1925,45 @@ private struct TimeRangeSelectorView: View {
|
||
| 1661 | 1925 |
} |
| 1662 | 1926 |
} |
| 1663 | 1927 |
|
| 1928 |
+ HStack(spacing: 8) {
|
|
| 1929 |
+ if !coversFullRange {
|
|
| 1930 |
+ actionButton( |
|
| 1931 |
+ title: compactLayout ? "Keep" : "Keep Selection", |
|
| 1932 |
+ systemName: "scissors", |
|
| 1933 |
+ tone: .destructive, |
|
| 1934 |
+ action: {
|
|
| 1935 |
+ onKeepSelection(currentRange) |
|
| 1936 |
+ } |
|
| 1937 |
+ ) |
|
| 1938 |
+ |
|
| 1939 |
+ actionButton( |
|
| 1940 |
+ title: compactLayout ? "Cut" : "Remove Selection", |
|
| 1941 |
+ systemName: "minus.circle", |
|
| 1942 |
+ tone: .destructive, |
|
| 1943 |
+ action: {
|
|
| 1944 |
+ onRemoveSelection(currentRange) |
|
| 1945 |
+ } |
|
| 1946 |
+ ) |
|
| 1947 |
+ } |
|
| 1948 |
+ |
|
| 1949 |
+ Spacer(minLength: 0) |
|
| 1950 |
+ |
|
| 1951 |
+ actionButton( |
|
| 1952 |
+ title: compactLayout ? "Reset" : "Reset Buffer", |
|
| 1953 |
+ systemName: "trash", |
|
| 1954 |
+ tone: .destructiveProminent, |
|
| 1955 |
+ action: {
|
|
| 1956 |
+ showResetConfirmation = true |
|
| 1957 |
+ } |
|
| 1958 |
+ ) |
|
| 1959 |
+ } |
|
| 1960 |
+ .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
|
| 1961 |
+ Button("Reset buffer", role: .destructive) {
|
|
| 1962 |
+ onResetBuffer() |
|
| 1963 |
+ } |
|
| 1964 |
+ Button("Cancel", role: .cancel) {}
|
|
| 1965 |
+ } |
|
| 1966 |
+ |
|
| 1664 | 1967 |
GeometryReader { geometry in
|
| 1665 | 1968 |
let selectionFrame = selectionFrame(in: geometry.size) |
| 1666 | 1969 |
let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58) |
@@ -1673,8 +1976,8 @@ private struct TimeRangeSelectorView: View {
|
||
| 1673 | 1976 |
points: points, |
| 1674 | 1977 |
context: context, |
| 1675 | 1978 |
areaChart: true, |
| 1676 |
- strokeColor: accentColor, |
|
| 1677 |
- areaFillColor: accentColor.opacity(0.22) |
|
| 1979 |
+ strokeColor: selectorTint, |
|
| 1980 |
+ areaFillColor: selectorTint.opacity(0.22) |
|
| 1678 | 1981 |
) |
| 1679 | 1982 |
.opacity(0.94) |
| 1680 | 1983 |
.allowsHitTesting(false) |
@@ -1682,7 +1985,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 1682 | 1985 |
Chart( |
| 1683 | 1986 |
points: points, |
| 1684 | 1987 |
context: context, |
| 1685 |
- strokeColor: accentColor.opacity(0.56) |
|
| 1988 |
+ strokeColor: selectorTint.opacity(0.56) |
|
| 1686 | 1989 |
) |
| 1687 | 1990 |
.opacity(0.82) |
| 1688 | 1991 |
.allowsHitTesting(false) |
@@ -1706,13 +2009,13 @@ private struct TimeRangeSelectorView: View {
|
||
| 1706 | 2009 |
} |
| 1707 | 2010 |
|
| 1708 | 2011 |
RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous) |
| 1709 |
- .fill(accentColor.opacity(0.18)) |
|
| 2012 |
+ .fill(selectorTint.opacity(0.18)) |
|
| 1710 | 2013 |
.frame(width: max(selectionFrame.width, 2), height: geometry.size.height) |
| 1711 | 2014 |
.offset(x: selectionFrame.minX) |
| 1712 | 2015 |
.allowsHitTesting(false) |
| 1713 | 2016 |
|
| 1714 | 2017 |
RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous) |
| 1715 |
- .stroke(accentColor.opacity(0.52), lineWidth: 1.2) |
|
| 2018 |
+ .stroke(selectorTint.opacity(0.52), lineWidth: 1.2) |
|
| 1716 | 2019 |
.frame(width: max(selectionFrame.width, 2), height: geometry.size.height) |
| 1717 | 2020 |
.offset(x: selectionFrame.minX) |
| 1718 | 2021 |
.allowsHitTesting(false) |
@@ -1765,14 +2068,14 @@ private struct TimeRangeSelectorView: View {
|
||
| 1765 | 2068 |
.frame(width: symbolButtonSize, height: symbolButtonSize) |
| 1766 | 2069 |
} |
| 1767 | 2070 |
.buttonStyle(.plain) |
| 1768 |
- .foregroundColor(isActive ? .white : accentColor) |
|
| 2071 |
+ .foregroundColor(isActive ? .white : selectorTint) |
|
| 1769 | 2072 |
.background( |
| 1770 | 2073 |
RoundedRectangle(cornerRadius: 9, style: .continuous) |
| 1771 |
- .fill(isActive ? accentColor : accentColor.opacity(0.14)) |
|
| 2074 |
+ .fill(isActive ? selectorTint : selectorTint.opacity(0.14)) |
|
| 1772 | 2075 |
) |
| 1773 | 2076 |
.overlay( |
| 1774 | 2077 |
RoundedRectangle(cornerRadius: 9, style: .continuous) |
| 1775 |
- .stroke(accentColor.opacity(0.28), lineWidth: 1) |
|
| 2078 |
+ .stroke(selectorTint.opacity(0.28), lineWidth: 1) |
|
| 1776 | 2079 |
) |
| 1777 | 2080 |
.accessibilityLabel(accessibilityLabel) |
| 1778 | 2081 |
} |
@@ -1791,16 +2094,80 @@ private struct TimeRangeSelectorView: View {
|
||
| 1791 | 2094 |
.foregroundColor(.white) |
| 1792 | 2095 |
.background( |
| 1793 | 2096 |
RoundedRectangle(cornerRadius: 9, style: .continuous) |
| 1794 |
- .fill(accentColor) |
|
| 2097 |
+ .fill(selectorTint) |
|
| 1795 | 2098 |
) |
| 1796 | 2099 |
.overlay( |
| 1797 | 2100 |
RoundedRectangle(cornerRadius: 9, style: .continuous) |
| 1798 |
- .stroke(accentColor.opacity(0.28), lineWidth: 1) |
|
| 2101 |
+ .stroke(selectorTint.opacity(0.28), lineWidth: 1) |
|
| 1799 | 2102 |
) |
| 1800 | 2103 |
.accessibilityLabel(trackingModeAccessibilityLabel) |
| 1801 | 2104 |
.accessibilityHint("Toggles how the interval follows the present")
|
| 1802 | 2105 |
} |
| 1803 | 2106 |
|
| 2107 |
+ private func actionButton( |
|
| 2108 |
+ title: String, |
|
| 2109 |
+ systemName: String, |
|
| 2110 |
+ tone: ActionTone, |
|
| 2111 |
+ action: @escaping () -> Void |
|
| 2112 |
+ ) -> some View {
|
|
| 2113 |
+ let foregroundColor: Color = {
|
|
| 2114 |
+ switch tone {
|
|
| 2115 |
+ case .reversible, .destructive: |
|
| 2116 |
+ return toneColor(for: tone) |
|
| 2117 |
+ case .destructiveProminent: |
|
| 2118 |
+ return .white |
|
| 2119 |
+ } |
|
| 2120 |
+ }() |
|
| 2121 |
+ |
|
| 2122 |
+ return Button(action: action) {
|
|
| 2123 |
+ Label(title, systemImage: systemName) |
|
| 2124 |
+ .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)) |
|
| 2125 |
+ .padding(.horizontal, compactLayout ? 10 : 12) |
|
| 2126 |
+ .padding(.vertical, compactLayout ? 7 : 8) |
|
| 2127 |
+ } |
|
| 2128 |
+ .buttonStyle(.plain) |
|
| 2129 |
+ .foregroundColor(foregroundColor) |
|
| 2130 |
+ .background( |
|
| 2131 |
+ RoundedRectangle(cornerRadius: 10, style: .continuous) |
|
| 2132 |
+ .fill(actionButtonBackground(for: tone)) |
|
| 2133 |
+ ) |
|
| 2134 |
+ .overlay( |
|
| 2135 |
+ RoundedRectangle(cornerRadius: 10, style: .continuous) |
|
| 2136 |
+ .stroke(actionButtonBorder(for: tone), lineWidth: 1) |
|
| 2137 |
+ ) |
|
| 2138 |
+ } |
|
| 2139 |
+ |
|
| 2140 |
+ private func toneColor(for tone: ActionTone) -> Color {
|
|
| 2141 |
+ switch tone {
|
|
| 2142 |
+ case .reversible: |
|
| 2143 |
+ return selectorTint |
|
| 2144 |
+ case .destructive, .destructiveProminent: |
|
| 2145 |
+ return .red |
|
| 2146 |
+ } |
|
| 2147 |
+ } |
|
| 2148 |
+ |
|
| 2149 |
+ private func actionButtonBackground(for tone: ActionTone) -> Color {
|
|
| 2150 |
+ switch tone {
|
|
| 2151 |
+ case .reversible: |
|
| 2152 |
+ return selectorTint.opacity(0.12) |
|
| 2153 |
+ case .destructive: |
|
| 2154 |
+ return Color.red.opacity(0.12) |
|
| 2155 |
+ case .destructiveProminent: |
|
| 2156 |
+ return Color.red.opacity(0.82) |
|
| 2157 |
+ } |
|
| 2158 |
+ } |
|
| 2159 |
+ |
|
| 2160 |
+ private func actionButtonBorder(for tone: ActionTone) -> Color {
|
|
| 2161 |
+ switch tone {
|
|
| 2162 |
+ case .reversible: |
|
| 2163 |
+ return selectorTint.opacity(0.22) |
|
| 2164 |
+ case .destructive: |
|
| 2165 |
+ return Color.red.opacity(0.22) |
|
| 2166 |
+ case .destructiveProminent: |
|
| 2167 |
+ return Color.red.opacity(0.72) |
|
| 2168 |
+ } |
|
| 2169 |
+ } |
|
| 2170 |
+ |
|
| 1804 | 2171 |
private var trackingModeSymbolName: String {
|
| 1805 | 2172 |
switch presentTrackingMode {
|
| 1806 | 2173 |
case .keepDuration: |
@@ -2154,12 +2521,6 @@ private struct TimeRangeSelectorView: View {
|
||
| 2154 | 2521 |
date.format(as: boundaryDateFormat) |
| 2155 | 2522 |
} |
| 2156 | 2523 |
|
| 2157 |
- private func selectionSummary( |
|
| 2158 |
- for range: ClosedRange<Date> |
|
| 2159 |
- ) -> String {
|
|
| 2160 |
- "\(range.lowerBound.format(as: summaryDateFormat)) - \(range.upperBound.format(as: summaryDateFormat))" |
|
| 2161 |
- } |
|
| 2162 |
- |
|
| 2163 | 2524 |
private var boundaryDateFormat: String {
|
| 2164 | 2525 |
switch totalSpan {
|
| 2165 | 2526 |
case 0..<86400: |
@@ -2170,21 +2531,12 @@ private struct TimeRangeSelectorView: View {
|
||
| 2170 | 2531 |
return "MMM d" |
| 2171 | 2532 |
} |
| 2172 | 2533 |
} |
| 2173 |
- |
|
| 2174 |
- private var summaryDateFormat: String {
|
|
| 2175 |
- switch totalSpan {
|
|
| 2176 |
- case 0..<3600: |
|
| 2177 |
- return "HH:mm:ss" |
|
| 2178 |
- case 3600..<172800: |
|
| 2179 |
- return "MMM d HH:mm" |
|
| 2180 |
- default: |
|
| 2181 |
- return "MMM d" |
|
| 2182 |
- } |
|
| 2183 |
- } |
|
| 2184 | 2534 |
} |
| 2185 | 2535 |
|
| 2186 | 2536 |
struct Chart : View {
|
| 2187 | 2537 |
|
| 2538 |
+ @Environment(\.displayScale) private var displayScale |
|
| 2539 |
+ |
|
| 2188 | 2540 |
let points: [Measurements.Measurement.Point] |
| 2189 | 2541 |
let context: ChartContext |
| 2190 | 2542 |
var areaChart: Bool = false |
@@ -2216,13 +2568,26 @@ struct Chart : View {
|
||
| 2216 | 2568 |
} |
| 2217 | 2569 |
|
| 2218 | 2570 |
fileprivate func path(geometry: GeometryProxy) -> Path {
|
| 2571 |
+ let displayedPoints = scaledPoints(for: geometry.size.width) |
|
| 2572 |
+ let baselineY = context.placeInRect( |
|
| 2573 |
+ point: CGPoint(x: context.origin.x, y: context.origin.y) |
|
| 2574 |
+ ).y * geometry.size.height |
|
| 2575 |
+ |
|
| 2219 | 2576 |
return Path { path in
|
| 2220 |
- var firstSample: Measurements.Measurement.Point? |
|
| 2221 |
- var lastSample: Measurements.Measurement.Point? |
|
| 2577 |
+ var firstRenderedPoint: CGPoint? |
|
| 2578 |
+ var lastRenderedPoint: CGPoint? |
|
| 2222 | 2579 |
var needsMove = true |
| 2223 | 2580 |
|
| 2224 |
- for point in points {
|
|
| 2581 |
+ for point in displayedPoints {
|
|
| 2225 | 2582 |
if point.isDiscontinuity {
|
| 2583 |
+ closeAreaSegment( |
|
| 2584 |
+ in: &path, |
|
| 2585 |
+ firstPoint: firstRenderedPoint, |
|
| 2586 |
+ lastPoint: lastRenderedPoint, |
|
| 2587 |
+ baselineY: baselineY |
|
| 2588 |
+ ) |
|
| 2589 |
+ firstRenderedPoint = nil |
|
| 2590 |
+ lastRenderedPoint = nil |
|
| 2226 | 2591 |
needsMove = true |
| 2227 | 2592 |
continue |
| 2228 | 2593 |
} |
@@ -2233,28 +2598,172 @@ struct Chart : View {
|
||
| 2233 | 2598 |
y: item.y * geometry.size.height |
| 2234 | 2599 |
) |
| 2235 | 2600 |
|
| 2236 |
- if firstSample == nil {
|
|
| 2237 |
- firstSample = point |
|
| 2238 |
- } |
|
| 2239 |
- lastSample = point |
|
| 2240 |
- |
|
| 2241 | 2601 |
if needsMove {
|
| 2242 | 2602 |
path.move(to: renderedPoint) |
| 2603 |
+ firstRenderedPoint = renderedPoint |
|
| 2243 | 2604 |
needsMove = false |
| 2244 | 2605 |
} else {
|
| 2245 | 2606 |
path.addLine(to: renderedPoint) |
| 2246 | 2607 |
} |
| 2608 |
+ |
|
| 2609 |
+ lastRenderedPoint = renderedPoint |
|
| 2247 | 2610 |
} |
| 2248 | 2611 |
|
| 2249 |
- if self.areaChart, let firstSample, let lastSample {
|
|
| 2250 |
- let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y )) |
|
| 2251 |
- let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y )) |
|
| 2252 |
- path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) ) |
|
| 2253 |
- path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) ) |
|
| 2254 |
- // MARK: Nu e nevoie. Fill inchide automat calea |
|
| 2255 |
- // path.closeSubpath() |
|
| 2612 |
+ closeAreaSegment( |
|
| 2613 |
+ in: &path, |
|
| 2614 |
+ firstPoint: firstRenderedPoint, |
|
| 2615 |
+ lastPoint: lastRenderedPoint, |
|
| 2616 |
+ baselineY: baselineY |
|
| 2617 |
+ ) |
|
| 2618 |
+ } |
|
| 2619 |
+ } |
|
| 2620 |
+ |
|
| 2621 |
+ private func closeAreaSegment( |
|
| 2622 |
+ in path: inout Path, |
|
| 2623 |
+ firstPoint: CGPoint?, |
|
| 2624 |
+ lastPoint: CGPoint?, |
|
| 2625 |
+ baselineY: CGFloat |
|
| 2626 |
+ ) {
|
|
| 2627 |
+ guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
|
|
| 2628 |
+ |
|
| 2629 |
+ path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY)) |
|
| 2630 |
+ path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY)) |
|
| 2631 |
+ path.closeSubpath() |
|
| 2632 |
+ } |
|
| 2633 |
+ |
|
| 2634 |
+ private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
|
|
| 2635 |
+ let sampleCount = points.reduce(into: 0) { partialResult, point in
|
|
| 2636 |
+ if point.isSample {
|
|
| 2637 |
+ partialResult += 1 |
|
| 2638 |
+ } |
|
| 2639 |
+ } |
|
| 2640 |
+ |
|
| 2641 |
+ let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1) |
|
| 2642 |
+ let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240) |
|
| 2643 |
+ |
|
| 2644 |
+ guard sampleCount > maximumSamplesToRender, context.isValid else {
|
|
| 2645 |
+ return points |
|
| 2646 |
+ } |
|
| 2647 |
+ |
|
| 2648 |
+ var scaledPoints: [Measurements.Measurement.Point] = [] |
|
| 2649 |
+ var currentSegment: [Measurements.Measurement.Point] = [] |
|
| 2650 |
+ |
|
| 2651 |
+ for point in points {
|
|
| 2652 |
+ if point.isDiscontinuity {
|
|
| 2653 |
+ appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns) |
|
| 2654 |
+ currentSegment.removeAll(keepingCapacity: true) |
|
| 2655 |
+ |
|
| 2656 |
+ if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
|
|
| 2657 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 2658 |
+ } |
|
| 2659 |
+ } else {
|
|
| 2660 |
+ currentSegment.append(point) |
|
| 2661 |
+ } |
|
| 2662 |
+ } |
|
| 2663 |
+ |
|
| 2664 |
+ appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns) |
|
| 2665 |
+ return scaledPoints.isEmpty ? points : scaledPoints |
|
| 2666 |
+ } |
|
| 2667 |
+ |
|
| 2668 |
+ private func appendScaledSegment( |
|
| 2669 |
+ _ segment: [Measurements.Measurement.Point], |
|
| 2670 |
+ to scaledPoints: inout [Measurements.Measurement.Point], |
|
| 2671 |
+ displayColumns: Int |
|
| 2672 |
+ ) {
|
|
| 2673 |
+ guard !segment.isEmpty else { return }
|
|
| 2674 |
+ |
|
| 2675 |
+ if segment.count <= max(displayColumns * 2, 120) {
|
|
| 2676 |
+ for point in segment {
|
|
| 2677 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 2678 |
+ } |
|
| 2679 |
+ return |
|
| 2680 |
+ } |
|
| 2681 |
+ |
|
| 2682 |
+ var bucket: [Measurements.Measurement.Point] = [] |
|
| 2683 |
+ var currentColumn: Int? |
|
| 2684 |
+ |
|
| 2685 |
+ for point in segment {
|
|
| 2686 |
+ let column = displayColumn(for: point, totalColumns: displayColumns) |
|
| 2687 |
+ |
|
| 2688 |
+ if let currentColumn, currentColumn != column {
|
|
| 2689 |
+ appendBucket(bucket, to: &scaledPoints) |
|
| 2690 |
+ bucket.removeAll(keepingCapacity: true) |
|
| 2256 | 2691 |
} |
| 2692 |
+ |
|
| 2693 |
+ bucket.append(point) |
|
| 2694 |
+ currentColumn = column |
|
| 2695 |
+ } |
|
| 2696 |
+ |
|
| 2697 |
+ appendBucket(bucket, to: &scaledPoints) |
|
| 2698 |
+ } |
|
| 2699 |
+ |
|
| 2700 |
+ private func appendBucket( |
|
| 2701 |
+ _ bucket: [Measurements.Measurement.Point], |
|
| 2702 |
+ to scaledPoints: inout [Measurements.Measurement.Point] |
|
| 2703 |
+ ) {
|
|
| 2704 |
+ guard !bucket.isEmpty else { return }
|
|
| 2705 |
+ |
|
| 2706 |
+ if bucket.count <= 2 {
|
|
| 2707 |
+ for point in bucket {
|
|
| 2708 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 2709 |
+ } |
|
| 2710 |
+ return |
|
| 2711 |
+ } |
|
| 2712 |
+ |
|
| 2713 |
+ let firstPoint = bucket.first! |
|
| 2714 |
+ let lastPoint = bucket.last! |
|
| 2715 |
+ let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
|
|
| 2716 |
+ let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
|
|
| 2717 |
+ |
|
| 2718 |
+ let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint] |
|
| 2719 |
+ .sorted { lhs, rhs in
|
|
| 2720 |
+ if lhs.timestamp == rhs.timestamp {
|
|
| 2721 |
+ return lhs.id < rhs.id |
|
| 2722 |
+ } |
|
| 2723 |
+ return lhs.timestamp < rhs.timestamp |
|
| 2724 |
+ } |
|
| 2725 |
+ |
|
| 2726 |
+ var emittedPointIDs: Set<Int> = [] |
|
| 2727 |
+ for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
|
|
| 2728 |
+ appendScaledPoint(point, to: &scaledPoints) |
|
| 2257 | 2729 |
} |
| 2258 | 2730 |
} |
| 2731 |
+ |
|
| 2732 |
+ private func appendScaledPoint( |
|
| 2733 |
+ _ point: Measurements.Measurement.Point, |
|
| 2734 |
+ to scaledPoints: inout [Measurements.Measurement.Point] |
|
| 2735 |
+ ) {
|
|
| 2736 |
+ guard !(scaledPoints.last?.timestamp == point.timestamp && |
|
| 2737 |
+ scaledPoints.last?.value == point.value && |
|
| 2738 |
+ scaledPoints.last?.kind == point.kind) else {
|
|
| 2739 |
+ return |
|
| 2740 |
+ } |
|
| 2741 |
+ |
|
| 2742 |
+ scaledPoints.append( |
|
| 2743 |
+ Measurements.Measurement.Point( |
|
| 2744 |
+ id: scaledPoints.count, |
|
| 2745 |
+ timestamp: point.timestamp, |
|
| 2746 |
+ value: point.value, |
|
| 2747 |
+ kind: point.kind |
|
| 2748 |
+ ) |
|
| 2749 |
+ ) |
|
| 2750 |
+ } |
|
| 2751 |
+ |
|
| 2752 |
+ private func displayColumn( |
|
| 2753 |
+ for point: Measurements.Measurement.Point, |
|
| 2754 |
+ totalColumns: Int |
|
| 2755 |
+ ) -> Int {
|
|
| 2756 |
+ let totalColumns = max(totalColumns, 1) |
|
| 2757 |
+ let timeSpan = max(Double(context.size.width), 1) |
|
| 2758 |
+ let normalizedOffset = min( |
|
| 2759 |
+ max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0), |
|
| 2760 |
+ 1 |
|
| 2761 |
+ ) |
|
| 2762 |
+ |
|
| 2763 |
+ return min( |
|
| 2764 |
+ Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)), |
|
| 2765 |
+ totalColumns - 1 |
|
| 2766 |
+ ) |
|
| 2767 |
+ } |
|
| 2259 | 2768 |
|
| 2260 | 2769 |
} |
@@ -42,7 +42,8 @@ struct MeasurementSeriesSheetView: View {
|
||
| 42 | 42 |
MeasurementSeriesSampleView( |
| 43 | 43 |
power: point, |
| 44 | 44 |
voltage: measurements.voltage.points[point.id], |
| 45 |
- current: measurements.current.points[point.id] |
|
| 45 |
+ current: measurements.current.points[point.id], |
|
| 46 |
+ energy: energyPoint(for: point.timestamp) |
|
| 46 | 47 |
) |
| 47 | 48 |
} |
| 48 | 49 |
} |
@@ -69,4 +70,8 @@ struct MeasurementSeriesSheetView: View {
|
||
| 69 | 70 |
} |
| 70 | 71 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 71 | 72 |
} |
| 73 |
+ |
|
| 74 |
+ private func energyPoint(for timestamp: Date) -> Measurements.Measurement.Point? {
|
|
| 75 |
+ measurements.energy.samplePoints.last { $0.timestamp == timestamp }
|
|
| 76 |
+ } |
|
| 72 | 77 |
} |
@@ -13,6 +13,7 @@ struct MeasurementSeriesSampleView: View {
|
||
| 13 | 13 |
var power: Measurements.Measurement.Point |
| 14 | 14 |
var voltage: Measurements.Measurement.Point |
| 15 | 15 |
var current: Measurements.Measurement.Point |
| 16 |
+ var energy: Measurements.Measurement.Point? |
|
| 16 | 17 |
|
| 17 | 18 |
@State var showDetail: Bool = false |
| 18 | 19 |
|
@@ -48,6 +49,9 @@ struct MeasurementSeriesSampleView: View {
|
||
| 48 | 49 |
detailRow(title: "Power", value: "\(power.value.format(fractionDigits: 4)) W") |
| 49 | 50 |
detailRow(title: "Voltage", value: "\(voltage.value.format(fractionDigits: 4)) V") |
| 50 | 51 |
detailRow(title: "Current", value: "\(current.value.format(fractionDigits: 4)) A") |
| 52 |
+ if let energy {
|
|
| 53 |
+ detailRow(title: "Energy", value: "\(energy.value.format(fractionDigits: 4)) Wh") |
|
| 54 |
+ } |
|
| 51 | 55 |
} |
| 52 | 56 |
} |
| 53 | 57 |
} |
@@ -80,6 +80,16 @@ struct MeterLiveContentView: View {
|
||
| 80 | 80 |
) |
| 81 | 81 |
} |
| 82 | 82 |
|
| 83 |
+ if shouldShowEnergyCard {
|
|
| 84 |
+ liveMetricCard( |
|
| 85 |
+ title: "Energy", |
|
| 86 |
+ symbol: "battery.100.bolt", |
|
| 87 |
+ color: .teal, |
|
| 88 |
+ value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh", |
|
| 89 |
+ detailText: "Buffered accumulated energy" |
|
| 90 |
+ ) |
|
| 91 |
+ } |
|
| 92 |
+ |
|
| 83 | 93 |
if shouldShowTemperatureCard {
|
| 84 | 94 |
liveMetricCard( |
| 85 | 95 |
title: "Temperature", |
@@ -162,10 +172,18 @@ struct MeterLiveContentView: View {
|
||
| 162 | 172 |
hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite |
| 163 | 173 |
} |
| 164 | 174 |
|
| 175 |
+ private var shouldShowEnergyCard: Bool {
|
|
| 176 |
+ hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite |
|
| 177 |
+ } |
|
| 178 |
+ |
|
| 165 | 179 |
private var shouldShowTemperatureCard: Bool {
|
| 166 | 180 |
hasLiveMetrics && meter.displayedTemperatureValue.isFinite |
| 167 | 181 |
} |
| 168 | 182 |
|
| 183 |
+ private var liveBufferedEnergyValue: Double {
|
|
| 184 |
+ meter.measurements.energy.samplePoints.last?.value ?? 0 |
|
| 185 |
+ } |
|
| 186 |
+ |
|
| 169 | 187 |
private var shouldShowLoadCard: Bool {
|
| 170 | 188 |
hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0 |
| 171 | 189 |
} |
@@ -409,7 +427,7 @@ private struct PowerAverageSheetView: View {
|
||
| 409 | 427 |
} |
| 410 | 428 |
|
| 411 | 429 |
MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
|
| 412 |
- Text("Reset clears the captured live measurement buffer for power, voltage, current, temperature, and RSSI.")
|
|
| 430 |
+ Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
|
|
| 413 | 431 |
.font(.footnote) |
| 414 | 432 |
.foregroundColor(.secondary) |
| 415 | 433 |
|