@@ -181,6 +181,21 @@ struct MeasurementChartView: View {
|
||
| 181 | 181 |
self.rangeSelectorConfiguration = rangeSelectorConfiguration |
| 182 | 182 |
} |
| 183 | 183 |
|
| 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) |
|
| 195 |
+ guard showsRangeSelector else { return plotHeight }
|
|
| 196 |
+ return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compactLayout) |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 184 | 199 |
private var axisColumnWidth: CGFloat {
|
| 185 | 200 |
if compactLayout {
|
| 186 | 201 |
return 38 |
@@ -335,120 +350,122 @@ struct MeasurementChartView: View {
|
||
| 335 | 350 |
VStack(alignment: .leading, spacing: 12) {
|
| 336 | 351 |
chartToggleBar() |
| 337 | 352 |
|
| 338 |
- GeometryReader { geometry in
|
|
| 339 |
- let reservedBottomHeight = |
|
| 340 |
- xAxisHeight |
|
| 341 |
- + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0) |
|
| 342 |
- let plotHeight = max( |
|
| 343 |
- geometry.size.height - reservedBottomHeight, |
|
| 344 |
- compactLayout ? 180 : 220 |
|
| 345 |
- ) |
|
| 353 |
+ VStack(spacing: compactLayout ? 8 : 10) {
|
|
| 354 |
+ GeometryReader { geometry in
|
|
| 355 |
+ let reservedBottomHeight = |
|
| 356 |
+ xAxisHeight |
|
| 357 |
+ + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0) |
|
| 358 |
+ let plotHeight = max( |
|
| 359 |
+ geometry.size.height - reservedBottomHeight, |
|
| 360 |
+ compactLayout ? 180 : 220 |
|
| 361 |
+ ) |
|
| 362 |
+ |
|
| 363 |
+ VStack(spacing: 6) {
|
|
| 364 |
+ HStack(spacing: chartSectionSpacing) {
|
|
| 365 |
+ primaryAxisView( |
|
| 366 |
+ height: plotHeight, |
|
| 367 |
+ powerSeries: powerSeries, |
|
| 368 |
+ energySeries: energySeries, |
|
| 369 |
+ voltageSeries: voltageSeries, |
|
| 370 |
+ currentSeries: currentSeries |
|
| 371 |
+ ) |
|
| 372 |
+ .frame(width: axisColumnWidth, height: plotHeight) |
|
| 373 |
+ |
|
| 374 |
+ ZStack {
|
|
| 375 |
+ RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 376 |
+ .fill(Color.primary.opacity(0.05)) |
|
| 377 |
+ |
|
| 378 |
+ RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 379 |
+ .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
|
| 380 |
+ |
|
| 381 |
+ horizontalGuides(context: primarySeries.context) |
|
| 382 |
+ verticalGuides(context: primarySeries.context) |
|
| 383 |
+ discontinuityMarkers(points: primarySeries.points, context: primarySeries.context) |
|
| 384 |
+ renderedChart( |
|
| 385 |
+ powerSeries: powerSeries, |
|
| 386 |
+ energySeries: energySeries, |
|
| 387 |
+ voltageSeries: voltageSeries, |
|
| 388 |
+ currentSeries: currentSeries, |
|
| 389 |
+ temperatureSeries: temperatureSeries |
|
| 390 |
+ ) |
|
| 391 |
+ } |
|
| 392 |
+ .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
|
| 393 |
+ .frame(maxWidth: .infinity) |
|
| 394 |
+ .frame(height: plotHeight) |
|
| 346 | 395 |
|
| 347 |
- VStack(spacing: 6) {
|
|
| 348 |
- HStack(spacing: chartSectionSpacing) {
|
|
| 349 |
- primaryAxisView( |
|
| 350 |
- height: plotHeight, |
|
| 351 |
- powerSeries: powerSeries, |
|
| 352 |
- energySeries: energySeries, |
|
| 353 |
- voltageSeries: voltageSeries, |
|
| 354 |
- currentSeries: currentSeries |
|
| 355 |
- ) |
|
| 356 |
- .frame(width: axisColumnWidth, height: plotHeight) |
|
| 357 |
- |
|
| 358 |
- ZStack {
|
|
| 359 |
- RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 360 |
- .fill(Color.primary.opacity(0.05)) |
|
| 361 |
- |
|
| 362 |
- RoundedRectangle(cornerRadius: 18, style: .continuous) |
|
| 363 |
- .stroke(Color.secondary.opacity(0.16), lineWidth: 1) |
|
| 364 |
- |
|
| 365 |
- horizontalGuides(context: primarySeries.context) |
|
| 366 |
- verticalGuides(context: primarySeries.context) |
|
| 367 |
- discontinuityMarkers(points: primarySeries.points, context: primarySeries.context) |
|
| 368 |
- renderedChart( |
|
| 396 |
+ secondaryAxisView( |
|
| 397 |
+ height: plotHeight, |
|
| 369 | 398 |
powerSeries: powerSeries, |
| 370 | 399 |
energySeries: energySeries, |
| 371 | 400 |
voltageSeries: voltageSeries, |
| 372 | 401 |
currentSeries: currentSeries, |
| 373 | 402 |
temperatureSeries: temperatureSeries |
| 374 | 403 |
) |
| 404 |
+ .frame(width: axisColumnWidth, height: plotHeight) |
|
| 375 | 405 |
} |
| 376 |
- .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
|
| 377 |
- .frame(maxWidth: .infinity) |
|
| 378 |
- .frame(height: plotHeight) |
|
| 379 |
- |
|
| 380 |
- secondaryAxisView( |
|
| 381 |
- height: plotHeight, |
|
| 382 |
- powerSeries: powerSeries, |
|
| 383 |
- energySeries: energySeries, |
|
| 384 |
- voltageSeries: voltageSeries, |
|
| 385 |
- currentSeries: currentSeries, |
|
| 386 |
- temperatureSeries: temperatureSeries |
|
| 387 |
- ) |
|
| 388 |
- .frame(width: axisColumnWidth, height: plotHeight) |
|
| 389 |
- } |
|
| 390 |
- .overlay(alignment: .bottom) {
|
|
| 391 |
- if originControlsPlacement == .aboveXAxisLegend {
|
|
| 392 |
- scaleControlsPill( |
|
| 393 |
- voltageSeries: voltageSeries, |
|
| 394 |
- currentSeries: currentSeries |
|
| 395 |
- ) |
|
| 396 |
- .padding(.bottom, compactLayout ? 6 : 10) |
|
| 406 |
+ .overlay(alignment: .bottom) {
|
|
| 407 |
+ if originControlsPlacement == .aboveXAxisLegend {
|
|
| 408 |
+ scaleControlsPill( |
|
| 409 |
+ voltageSeries: voltageSeries, |
|
| 410 |
+ currentSeries: currentSeries |
|
| 411 |
+ ) |
|
| 412 |
+ .padding(.bottom, compactLayout ? 6 : 10) |
|
| 413 |
+ } |
|
| 397 | 414 |
} |
| 398 |
- } |
|
| 399 | 415 |
|
| 400 |
- switch originControlsPlacement {
|
|
| 401 |
- case .aboveXAxisLegend: |
|
| 402 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 403 |
- .frame(height: xAxisHeight) |
|
| 404 |
- case .overXAxisLegend: |
|
| 405 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 406 |
- .frame(height: xAxisHeight) |
|
| 407 |
- .overlay(alignment: .center) {
|
|
| 416 |
+ switch originControlsPlacement {
|
|
| 417 |
+ case .aboveXAxisLegend: |
|
| 418 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 419 |
+ .frame(height: xAxisHeight) |
|
| 420 |
+ case .overXAxisLegend: |
|
| 421 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 422 |
+ .frame(height: xAxisHeight) |
|
| 423 |
+ .overlay(alignment: .center) {
|
|
| 424 |
+ scaleControlsPill( |
|
| 425 |
+ voltageSeries: voltageSeries, |
|
| 426 |
+ currentSeries: currentSeries |
|
| 427 |
+ ) |
|
| 428 |
+ .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10)) |
|
| 429 |
+ } |
|
| 430 |
+ case .belowXAxisLegend: |
|
| 431 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 432 |
+ .frame(height: xAxisHeight) |
|
| 433 |
+ |
|
| 434 |
+ HStack {
|
|
| 435 |
+ Spacer(minLength: 0) |
|
| 408 | 436 |
scaleControlsPill( |
| 409 | 437 |
voltageSeries: voltageSeries, |
| 410 | 438 |
currentSeries: currentSeries |
| 411 | 439 |
) |
| 412 |
- .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10)) |
|
| 440 |
+ Spacer(minLength: 0) |
|
| 413 | 441 |
} |
| 414 |
- case .belowXAxisLegend: |
|
| 415 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 416 |
- .frame(height: xAxisHeight) |
|
| 417 |
- |
|
| 418 |
- HStack {
|
|
| 419 |
- Spacer(minLength: 0) |
|
| 420 |
- scaleControlsPill( |
|
| 421 |
- voltageSeries: voltageSeries, |
|
| 422 |
- currentSeries: currentSeries |
|
| 423 |
- ) |
|
| 424 |
- Spacer(minLength: 0) |
|
| 425 | 442 |
} |
| 426 | 443 |
} |
| 427 |
- |
|
| 428 |
- if showsRangeSelector, |
|
| 429 |
- let availableTimeRange, |
|
| 430 |
- let selectorSeries, |
|
| 431 |
- shouldShowRangeSelector( |
|
| 444 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
|
| 445 |
+ } |
|
| 446 |
+ .frame(height: plotSectionHeight) |
|
| 447 |
+ |
|
| 448 |
+ if showsRangeSelector, |
|
| 449 |
+ let availableTimeRange, |
|
| 450 |
+ let selectorSeries, |
|
| 451 |
+ shouldShowRangeSelector( |
|
| 452 |
+ availableTimeRange: availableTimeRange, |
|
| 453 |
+ series: selectorSeries |
|
| 454 |
+ ) {
|
|
| 455 |
+ TimeRangeSelectorView( |
|
| 456 |
+ points: selectorSeries.points, |
|
| 457 |
+ context: selectorSeries.context, |
|
| 432 | 458 |
availableTimeRange: availableTimeRange, |
| 433 |
- series: selectorSeries |
|
| 434 |
- ) {
|
|
| 435 |
- TimeRangeSelectorView( |
|
| 436 |
- points: selectorSeries.points, |
|
| 437 |
- context: selectorSeries.context, |
|
| 438 |
- availableTimeRange: availableTimeRange, |
|
| 439 |
- selectorTint: selectorTint, |
|
| 440 |
- compactLayout: compactLayout, |
|
| 441 |
- minimumSelectionSpan: minimumTimeSpan, |
|
| 442 |
- configuration: resolvedRangeSelectorConfiguration(), |
|
| 443 |
- selectedTimeRange: $selectedVisibleTimeRange, |
|
| 444 |
- isPinnedToPresent: $isPinnedToPresent, |
|
| 445 |
- presentTrackingMode: $presentTrackingMode |
|
| 446 |
- ) |
|
| 447 |
- } |
|
| 459 |
+ selectorTint: selectorTint, |
|
| 460 |
+ compactLayout: compactLayout, |
|
| 461 |
+ minimumSelectionSpan: minimumTimeSpan, |
|
| 462 |
+ configuration: resolvedRangeSelectorConfiguration(), |
|
| 463 |
+ selectedTimeRange: $selectedVisibleTimeRange, |
|
| 464 |
+ isPinnedToPresent: $isPinnedToPresent, |
|
| 465 |
+ presentTrackingMode: $presentTrackingMode |
|
| 466 |
+ ) |
|
| 448 | 467 |
} |
| 449 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
|
| 450 | 468 |
} |
| 451 |
- .frame(height: plotSectionHeight) |
|
| 452 | 469 |
} |
| 453 | 470 |
} else {
|
| 454 | 471 |
VStack(alignment: .leading, spacing: 12) {
|
@@ -2009,6 +2026,14 @@ private struct TimeRangeSelectorView: View {
|
||
| 2009 | 2026 |
compactLayout ? 72 : 86 |
| 2010 | 2027 |
} |
| 2011 | 2028 |
|
| 2029 |
+ static func recommendedReservedHeight(compactLayout: Bool) -> CGFloat {
|
|
| 2030 |
+ let rowHeight: CGFloat = compactLayout ? 28 : 32 |
|
| 2031 |
+ let trackHeight: CGFloat = compactLayout ? 72 : 86 |
|
| 2032 |
+ let boundaryHeight: CGFloat = compactLayout ? 16 : 18 |
|
| 2033 |
+ let spacing: CGFloat = compactLayout ? 6 : 8 |
|
| 2034 |
+ return rowHeight + spacing + rowHeight + spacing + trackHeight + spacing + boundaryHeight |
|
| 2035 |
+ } |
|
| 2036 |
+ |
|
| 2012 | 2037 |
private var cornerRadius: CGFloat {
|
| 2013 | 2038 |
compactLayout ? 14 : 16 |
| 2014 | 2039 |
} |
@@ -15,6 +15,17 @@ 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 |
+ |
|
| 18 | 29 |
struct MeterChargeRecordContentView: View {
|
| 19 | 30 |
private struct SessionMetricRow {
|
| 20 | 31 |
let label: String |
@@ -115,6 +126,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 115 | 126 |
@State private var activeMode: ActiveMode = .chargeSession |
| 116 | 127 |
@State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow? |
| 117 | 128 |
@State private var trimBannerDismissedForSessionID: UUID? |
| 129 |
+ @State private var sessionChartWidth: CGFloat = 760 |
|
| 118 | 130 |
|
| 119 | 131 |
private var shouldShowTrimBanner: Bool {
|
| 120 | 132 |
guard let session = openChargeSession, |
@@ -1380,7 +1392,12 @@ struct MeterChargeRecordContentView: View {
|
||
| 1380 | 1392 |
} |
| 1381 | 1393 |
|
| 1382 | 1394 |
private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
|
| 1383 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 1395 |
+ 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 |
+ |
|
| 1400 |
+ return VStack(alignment: .leading, spacing: 12) {
|
|
| 1384 | 1401 |
HStack(spacing: 8) {
|
| 1385 | 1402 |
Image(systemName: "chart.xyaxis.line") |
| 1386 | 1403 |
.foregroundColor(.blue) |
@@ -1395,53 +1412,56 @@ struct MeterChargeRecordContentView: View {
|
||
| 1395 | 1412 |
Spacer(minLength: 0) |
| 1396 | 1413 |
} |
| 1397 | 1414 |
|
| 1398 |
- GeometryReader { geometry in
|
|
| 1399 |
- let chartWidth = max(geometry.size.width, 1) |
|
| 1400 |
- let compactChartLayout = chartWidth < 760 |
|
| 1401 |
- let chartHeight = compactChartLayout ? 290.0 : 350.0 |
|
| 1402 |
- |
|
| 1403 |
- MeasurementChartView( |
|
| 1404 |
- compactLayout: compactChartLayout, |
|
| 1405 |
- availableSize: CGSize(width: chartWidth, height: chartHeight), |
|
| 1406 |
- timeRange: timeRange, |
|
| 1407 |
- timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower, |
|
| 1408 |
- timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper, |
|
| 1409 |
- showsRangeSelector: session.aggregatedSamples.isEmpty == false, |
|
| 1410 |
- rebasesEnergyToVisibleRangeStart: true, |
|
| 1411 |
- extendsTimelineToPresent: false, |
|
| 1412 |
- rangeSelectorConfiguration: session.aggregatedSamples.isEmpty |
|
| 1413 |
- ? nil |
|
| 1414 |
- : MeasurementChartRangeSelectorConfiguration( |
|
| 1415 |
- keepAction: MeasurementChartSelectionAction( |
|
| 1416 |
- title: compactChartLayout ? "Keep" : "Keep Selection", |
|
| 1417 |
- systemName: "scissors", |
|
| 1418 |
- tone: .destructive, |
|
| 1419 |
- handler: { range in
|
|
| 1420 |
- _ = appData.setSessionTrim( |
|
| 1421 |
- sessionID: session.id, |
|
| 1422 |
- start: range.lowerBound, |
|
| 1423 |
- end: range.upperBound |
|
| 1424 |
- ) |
|
| 1425 |
- trimBannerDismissedForSessionID = session.id |
|
| 1426 |
- } |
|
| 1427 |
- ), |
|
| 1428 |
- removeAction: nil, |
|
| 1429 |
- resetAction: MeasurementChartResetAction( |
|
| 1430 |
- title: compactChartLayout ? "Reset" : "Reset Trim", |
|
| 1431 |
- systemName: "arrow.counterclockwise", |
|
| 1432 |
- tone: .reversible, |
|
| 1433 |
- confirmationTitle: "Reset session trim?", |
|
| 1434 |
- confirmationButtonTitle: "Reset trim", |
|
| 1435 |
- handler: {
|
|
| 1436 |
- _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil) |
|
| 1437 |
- } |
|
| 1438 |
- ) |
|
| 1415 |
+ MeasurementChartView( |
|
| 1416 |
+ compactLayout: compactChartLayout, |
|
| 1417 |
+ availableSize: CGSize(width: chartWidth, height: plotReferenceHeight), |
|
| 1418 |
+ timeRange: timeRange, |
|
| 1419 |
+ timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower, |
|
| 1420 |
+ timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper, |
|
| 1421 |
+ showsRangeSelector: hasRangeSelector, |
|
| 1422 |
+ rebasesEnergyToVisibleRangeStart: true, |
|
| 1423 |
+ extendsTimelineToPresent: false, |
|
| 1424 |
+ rangeSelectorConfiguration: hasRangeSelector |
|
| 1425 |
+ ? MeasurementChartRangeSelectorConfiguration( |
|
| 1426 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 1427 |
+ title: compactChartLayout ? "Keep" : "Keep Selection", |
|
| 1428 |
+ systemName: "scissors", |
|
| 1429 |
+ tone: .destructive, |
|
| 1430 |
+ handler: { range in
|
|
| 1431 |
+ _ = appData.setSessionTrim( |
|
| 1432 |
+ sessionID: session.id, |
|
| 1433 |
+ start: range.lowerBound, |
|
| 1434 |
+ end: range.upperBound |
|
| 1435 |
+ ) |
|
| 1436 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1437 |
+ } |
|
| 1438 |
+ ), |
|
| 1439 |
+ removeAction: nil, |
|
| 1440 |
+ resetAction: MeasurementChartResetAction( |
|
| 1441 |
+ title: compactChartLayout ? "Reset" : "Reset Trim", |
|
| 1442 |
+ systemName: "arrow.counterclockwise", |
|
| 1443 |
+ tone: .reversible, |
|
| 1444 |
+ confirmationTitle: "Reset session trim?", |
|
| 1445 |
+ confirmationButtonTitle: "Reset trim", |
|
| 1446 |
+ handler: {
|
|
| 1447 |
+ _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil) |
|
| 1448 |
+ } |
|
| 1439 | 1449 |
) |
| 1440 |
- ) |
|
| 1441 |
- .environmentObject(usbMeter.chargeRecordMeasurements) |
|
| 1442 |
- .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 1450 |
+ ) |
|
| 1451 |
+ : nil |
|
| 1452 |
+ ) |
|
| 1453 |
+ .environmentObject(usbMeter.chargeRecordMeasurements) |
|
| 1454 |
+ .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 |
|
| 1443 | 1464 |
} |
| 1444 |
- .frame(height: 350) |
|
| 1445 | 1465 |
} |
| 1446 | 1466 |
.padding(18) |
| 1447 | 1467 |
.meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20) |