- Embedded mode now sizes to content instead of a pre-computed fixed frame - plotSectionHeight returns fixed values for embedded (240/300) avoiding the proportional calculation mismatch that left ~50px blank at the bottom - Legend moved above the range selector so the interval selector stays at the bottom of the card - Range selector x-axis labels clamped within bounds so first/last labels no longer overflow the card edge
@@ -115,13 +115,23 @@ struct MeasurementChartView: View {
|
||
| 115 | 115 |
} |
| 116 | 116 |
} |
| 117 | 117 |
|
| 118 |
- private enum SeriesKind {
|
|
| 118 |
+ private enum SeriesKind: Hashable {
|
|
| 119 | 119 |
case power |
| 120 | 120 |
case energy |
| 121 | 121 |
case voltage |
| 122 | 122 |
case current |
| 123 | 123 |
case temperature |
| 124 | 124 |
|
| 125 |
+ var displayName: String {
|
|
| 126 |
+ switch self {
|
|
| 127 |
+ case .power: return "Power" |
|
| 128 |
+ case .energy: return "Energy" |
|
| 129 |
+ case .voltage: return "Voltage" |
|
| 130 |
+ case .current: return "Current" |
|
| 131 |
+ case .temperature: return "Temperature" |
|
| 132 |
+ } |
|
| 133 |
+ } |
|
| 134 |
+ |
|
| 125 | 135 |
var unit: String {
|
| 126 | 136 |
switch self {
|
| 127 | 137 |
case .power: return "W" |
@@ -153,6 +163,16 @@ struct MeasurementChartView: View {
|
||
| 153 | 163 |
let maximumSampleValue: Double? |
| 154 | 164 |
} |
| 155 | 165 |
|
| 166 |
+ private struct SeriesLegendEntry: Identifiable {
|
|
| 167 |
+ let id: SeriesKind |
|
| 168 |
+ let name: String |
|
| 169 |
+ let tint: Color |
|
| 170 |
+ let minimumText: String |
|
| 171 |
+ let averageText: String |
|
| 172 |
+ let maximumText: String |
|
| 173 |
+ let lastText: String |
|
| 174 |
+ } |
|
| 175 |
+ |
|
| 156 | 176 |
private let minimumTimeSpan: TimeInterval = 1 |
| 157 | 177 |
private let minimumVoltageSpan = 0.5 |
| 158 | 178 |
private let minimumCurrentSpan = 0.5 |
@@ -240,9 +260,25 @@ struct MeasurementChartView: View {
|
||
| 240 | 260 |
|
| 241 | 261 |
private static func embeddedContentHeight(width: CGFloat, showsRangeSelector: Bool) -> CGFloat {
|
| 242 | 262 |
let compact = width < 760 |
| 243 |
- let plotHeight: CGFloat = compact ? 290 : 350 |
|
| 244 |
- guard showsRangeSelector else { return plotHeight }
|
|
| 245 |
- return plotHeight + TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact) |
|
| 263 |
+ let plotHeight: CGFloat = compact ? 240 : 300 |
|
| 264 |
+ let toolbarHeight: CGFloat = width < 640 |
|
| 265 |
+ ? (compact ? 92 : 104) |
|
| 266 |
+ : (compact ? 48 : 56) |
|
| 267 |
+ let legendHeight: CGFloat = compact ? 76 : 90 |
|
| 268 |
+ let outerSpacing: CGFloat = 12 |
|
| 269 |
+ let chartStackSpacing: CGFloat = compact ? 8 : 10 |
|
| 270 |
+ let selectorHeight = showsRangeSelector |
|
| 271 |
+ ? TimeRangeSelectorView.recommendedReservedHeight(compactLayout: compact) |
|
| 272 |
+ : 0 |
|
| 273 |
+ let selectorSpacing = showsRangeSelector ? chartStackSpacing : 0 |
|
| 274 |
+ |
|
| 275 |
+ return toolbarHeight |
|
| 276 |
+ + outerSpacing |
|
| 277 |
+ + plotHeight |
|
| 278 |
+ + selectorSpacing |
|
| 279 |
+ + selectorHeight |
|
| 280 |
+ + chartStackSpacing |
|
| 281 |
+ + legendHeight |
|
| 246 | 282 |
} |
| 247 | 283 |
|
| 248 | 284 |
private var axisColumnWidth: CGFloat {
|
@@ -263,16 +299,6 @@ struct MeasurementChartView: View {
|
||
| 263 | 299 |
return isLargeDisplay ? 36 : 28 |
| 264 | 300 |
} |
| 265 | 301 |
|
| 266 |
- private var belowXAxisControlsHeight: CGFloat {
|
|
| 267 |
- if usesCompactLandscapeOriginControls {
|
|
| 268 |
- return 40 |
|
| 269 |
- } |
|
| 270 |
- if compactLayout {
|
|
| 271 |
- return 46 |
|
| 272 |
- } |
|
| 273 |
- return isLargeDisplay ? 58 : 50 |
|
| 274 |
- } |
|
| 275 |
- |
|
| 276 | 302 |
private var isPortraitLayout: Bool {
|
| 277 | 303 |
guard availableSize != .zero else { return verticalSizeClass != .compact }
|
| 278 | 304 |
return availableSize.height >= availableSize.width |
@@ -286,20 +312,11 @@ struct MeasurementChartView: View {
|
||
| 286 | 312 |
#endif |
| 287 | 313 |
} |
| 288 | 314 |
|
| 289 |
- private enum OriginControlsPlacement {
|
|
| 290 |
- case aboveXAxisLegend |
|
| 291 |
- case overXAxisLegend |
|
| 292 |
- case belowXAxisLegend |
|
| 293 |
- } |
|
| 294 |
- |
|
| 295 |
- private var originControlsPlacement: OriginControlsPlacement {
|
|
| 296 |
- if isIPhone {
|
|
| 297 |
- return isPortraitLayout ? .aboveXAxisLegend : .overXAxisLegend |
|
| 315 |
+ private var plotSectionHeight: CGFloat {
|
|
| 316 |
+ if case .embedded = sizing {
|
|
| 317 |
+ return compactLayout ? 240 : 300 |
|
| 298 | 318 |
} |
| 299 |
- return .belowXAxisLegend |
|
| 300 |
- } |
|
| 301 | 319 |
|
| 302 |
- private var plotSectionHeight: CGFloat {
|
|
| 303 | 320 |
if availableSize == .zero {
|
| 304 | 321 |
return compactLayout ? 300 : 380 |
| 305 | 322 |
} |
@@ -358,11 +375,10 @@ struct MeasurementChartView: View {
|
||
| 358 | 375 |
switch sizing {
|
| 359 | 376 |
case .provided: |
| 360 | 377 |
chartBody |
| 378 |
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 361 | 379 |
case .embedded: |
| 362 |
- let chartWidth = max(embeddedWidth, 1) |
|
| 363 | 380 |
chartBody |
| 364 | 381 |
.frame(maxWidth: .infinity, alignment: .topLeading) |
| 365 |
- .frame(height: Self.embeddedContentHeight(width: chartWidth, showsRangeSelector: showsRangeSelector)) |
|
| 366 | 382 |
.background( |
| 367 | 383 |
GeometryReader { geometry in
|
| 368 | 384 |
Color.clear.preference(key: EmbeddedWidthKey.self, value: geometry.size.width) |
@@ -430,17 +446,17 @@ struct MeasurementChartView: View {
|
||
| 430 | 446 |
Group {
|
| 431 | 447 |
if let primarySeries {
|
| 432 | 448 |
VStack(alignment: .leading, spacing: 12) {
|
| 433 |
- chartToggleBar() |
|
| 449 |
+ chartTopToolbar( |
|
| 450 |
+ voltageSeries: voltageSeries, |
|
| 451 |
+ currentSeries: currentSeries |
|
| 452 |
+ ) |
|
| 434 | 453 |
|
| 435 | 454 |
VStack(spacing: compactLayout ? 8 : 10) {
|
| 436 | 455 |
GeometryReader { geometry in
|
| 437 |
- let reservedBottomHeight = |
|
| 438 |
- xAxisHeight |
|
| 439 |
- + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0) |
|
| 440 |
- let plotHeight = max( |
|
| 441 |
- geometry.size.height - reservedBottomHeight, |
|
| 442 |
- compactLayout ? 180 : 220 |
|
| 443 |
- ) |
|
| 456 |
+ let minimumPlotHeight: CGFloat = compactLayout |
|
| 457 |
+ ? (isPortraitLayout ? 180 : 120) |
|
| 458 |
+ : 220 |
|
| 459 |
+ let plotHeight = max(geometry.size.height - xAxisHeight, minimumPlotHeight) |
|
| 444 | 460 |
|
| 445 | 461 |
VStack(spacing: 6) {
|
| 446 | 462 |
HStack(spacing: chartSectionSpacing) {
|
@@ -485,48 +501,24 @@ struct MeasurementChartView: View {
|
||
| 485 | 501 |
) |
| 486 | 502 |
.frame(width: axisColumnWidth, height: plotHeight) |
| 487 | 503 |
} |
| 488 |
- .overlay(alignment: .bottom) {
|
|
| 489 |
- if originControlsPlacement == .aboveXAxisLegend {
|
|
| 490 |
- scaleControlsPill( |
|
| 491 |
- voltageSeries: voltageSeries, |
|
| 492 |
- currentSeries: currentSeries |
|
| 493 |
- ) |
|
| 494 |
- .padding(.bottom, compactLayout ? 6 : 10) |
|
| 495 |
- } |
|
| 496 |
- } |
|
| 497 | 504 |
|
| 498 |
- switch originControlsPlacement {
|
|
| 499 |
- case .aboveXAxisLegend: |
|
| 500 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 501 |
- .frame(height: xAxisHeight) |
|
| 502 |
- case .overXAxisLegend: |
|
| 503 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 504 |
- .frame(height: xAxisHeight) |
|
| 505 |
- .overlay(alignment: .center) {
|
|
| 506 |
- scaleControlsPill( |
|
| 507 |
- voltageSeries: voltageSeries, |
|
| 508 |
- currentSeries: currentSeries |
|
| 509 |
- ) |
|
| 510 |
- .offset(y: usesCompactLandscapeOriginControls ? 2 : (compactLayout ? 8 : 10)) |
|
| 511 |
- } |
|
| 512 |
- case .belowXAxisLegend: |
|
| 513 |
- xAxisLabelsView(context: primarySeries.context) |
|
| 514 |
- .frame(height: xAxisHeight) |
|
| 515 |
- |
|
| 516 |
- HStack {
|
|
| 517 |
- Spacer(minLength: 0) |
|
| 518 |
- scaleControlsPill( |
|
| 519 |
- voltageSeries: voltageSeries, |
|
| 520 |
- currentSeries: currentSeries |
|
| 521 |
- ) |
|
| 522 |
- Spacer(minLength: 0) |
|
| 523 |
- } |
|
| 524 |
- } |
|
| 505 |
+ xAxisLabelsView(context: primarySeries.context) |
|
| 506 |
+ .frame(height: xAxisHeight) |
|
| 525 | 507 |
} |
| 526 | 508 |
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| 527 | 509 |
} |
| 528 | 510 |
.frame(height: plotSectionHeight) |
| 529 | 511 |
|
| 512 |
+ chartLegend( |
|
| 513 |
+ entries: chartLegendEntries( |
|
| 514 |
+ powerSeries: powerSeries, |
|
| 515 |
+ energySeries: energySeries, |
|
| 516 |
+ voltageSeries: voltageSeries, |
|
| 517 |
+ currentSeries: currentSeries, |
|
| 518 |
+ temperatureSeries: temperatureSeries |
|
| 519 |
+ ) |
|
| 520 |
+ ) |
|
| 521 |
+ |
|
| 530 | 522 |
if showsRangeSelector, |
| 531 | 523 |
let availableTimeRange, |
| 532 | 524 |
let selectorSeries, |
@@ -552,25 +544,31 @@ struct MeasurementChartView: View {
|
||
| 552 | 544 |
} |
| 553 | 545 |
} else {
|
| 554 | 546 |
VStack(alignment: .leading, spacing: 12) {
|
| 555 |
- chartToggleBar() |
|
| 547 |
+ chartTopToolbar( |
|
| 548 |
+ voltageSeries: voltageSeries, |
|
| 549 |
+ currentSeries: currentSeries |
|
| 550 |
+ ) |
|
| 556 | 551 |
Text("Select at least one measurement series.")
|
| 557 | 552 |
.foregroundColor(.secondary) |
| 558 | 553 |
} |
| 559 | 554 |
} |
| 560 | 555 |
} |
| 561 | 556 |
.font(chartBaseFont) |
| 562 |
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) |
|
| 557 |
+ .frame(maxWidth: .infinity, alignment: .topLeading) |
|
| 563 | 558 |
.onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { now in
|
| 564 | 559 |
guard extendsTimelineToPresent, timeRange == nil, timeRangeUpperBound == nil else { return }
|
| 565 | 560 |
chartNow = now |
| 566 | 561 |
} |
| 567 | 562 |
} |
| 568 | 563 |
|
| 569 |
- private func chartToggleBar() -> some View {
|
|
| 564 |
+ private func chartTopToolbar( |
|
| 565 |
+ voltageSeries: SeriesData, |
|
| 566 |
+ currentSeries: SeriesData |
|
| 567 |
+ ) -> some View {
|
|
| 570 | 568 |
let condensedLayout = compactLayout || verticalSizeClass == .compact |
| 571 | 569 |
let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10) |
| 572 | 570 |
|
| 573 |
- let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
|
|
| 571 |
+ let seriesPanel = HStack(alignment: .center, spacing: sectionSpacing) {
|
|
| 574 | 572 |
seriesToggleRow(condensedLayout: condensedLayout) |
| 575 | 573 |
} |
| 576 | 574 |
.padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12)) |
@@ -584,59 +582,205 @@ struct MeasurementChartView: View {
|
||
| 584 | 582 |
.stroke(Color.secondary.opacity(0.14), lineWidth: 1) |
| 585 | 583 |
) |
| 586 | 584 |
|
| 585 |
+ let controlPanel = chartControlsPanel( |
|
| 586 |
+ voltageSeries: voltageSeries, |
|
| 587 |
+ currentSeries: currentSeries, |
|
| 588 |
+ condensedLayout: condensedLayout |
|
| 589 |
+ ) |
|
| 590 |
+ |
|
| 587 | 591 |
return Group {
|
| 588 | 592 |
if stackedToolbarLayout {
|
| 589 |
- controlsPanel |
|
| 593 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 594 |
+ seriesPanel |
|
| 595 |
+ controlPanel |
|
| 596 |
+ } |
|
| 590 | 597 |
} else {
|
| 591 |
- HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
|
|
| 592 |
- controlsPanel |
|
| 598 |
+ HStack(alignment: .center, spacing: isLargeDisplay ? 14 : 12) {
|
|
| 599 |
+ seriesPanel |
|
| 600 |
+ Spacer(minLength: 0) |
|
| 601 |
+ controlPanel |
|
| 593 | 602 |
} |
| 594 | 603 |
} |
| 595 | 604 |
} |
| 596 | 605 |
.frame(maxWidth: .infinity, alignment: .leading) |
| 597 | 606 |
} |
| 598 | 607 |
|
| 599 |
- private var shouldFloatScaleControlsOverChart: Bool {
|
|
| 600 |
- #if os(iOS) |
|
| 601 |
- if availableSize.width > 0, availableSize.height > 0 {
|
|
| 602 |
- return availableSize.width > availableSize.height |
|
| 603 |
- } |
|
| 604 |
- return horizontalSizeClass != .compact && verticalSizeClass == .compact |
|
| 605 |
- #else |
|
| 606 |
- return false |
|
| 607 |
- #endif |
|
| 608 |
- } |
|
| 609 |
- |
|
| 610 |
- private func scaleControlsPill( |
|
| 608 |
+ private func chartControlsPanel( |
|
| 611 | 609 |
voltageSeries: SeriesData, |
| 612 |
- currentSeries: SeriesData |
|
| 610 |
+ currentSeries: SeriesData, |
|
| 611 |
+ condensedLayout: Bool |
|
| 613 | 612 |
) -> some View {
|
| 614 |
- let condensedLayout = compactLayout || verticalSizeClass == .compact |
|
| 615 |
- let showsCardBackground = !(isIPhone && isPortraitLayout) && !shouldFloatScaleControlsOverChart |
|
| 616 |
- let horizontalPadding: CGFloat = usesCompactLandscapeOriginControls ? 6 : (condensedLayout ? 10 : (isLargeDisplay ? 12 : 10)) |
|
| 617 |
- let verticalPadding: CGFloat = usesCompactLandscapeOriginControls ? 5 : (condensedLayout ? 8 : (isLargeDisplay ? 10 : 8)) |
|
| 618 |
- |
|
| 619 |
- return originControlsRow( |
|
| 613 |
+ originControlsRow( |
|
| 620 | 614 |
voltageSeries: voltageSeries, |
| 621 | 615 |
currentSeries: currentSeries, |
| 622 | 616 |
condensedLayout: condensedLayout, |
| 623 |
- showsLabel: !shouldFloatScaleControlsOverChart && showsLabeledOriginControls |
|
| 617 |
+ showsLabel: showsLabeledOriginControls && !stackedToolbarLayout |
|
| 624 | 618 |
) |
| 625 |
- .padding(.horizontal, horizontalPadding) |
|
| 626 |
- .padding(.vertical, verticalPadding) |
|
| 619 |
+ .padding(.horizontal, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)) |
|
| 620 |
+ .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 10 : 8)) |
|
| 627 | 621 |
.background( |
| 628 |
- Capsule(style: .continuous) |
|
| 629 |
- .fill(showsCardBackground ? Color.primary.opacity(0.08) : Color.clear) |
|
| 622 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous) |
|
| 623 |
+ .fill(Color.primary.opacity(0.045)) |
|
| 630 | 624 |
) |
| 631 | 625 |
.overlay( |
| 632 |
- Capsule(style: .continuous) |
|
| 633 |
- .stroke( |
|
| 634 |
- showsCardBackground ? Color.secondary.opacity(0.18) : Color.clear, |
|
| 635 |
- lineWidth: 1 |
|
| 636 |
- ) |
|
| 626 |
+ RoundedRectangle(cornerRadius: condensedLayout ? 14 : 16, style: .continuous) |
|
| 627 |
+ .stroke(Color.secondary.opacity(0.14), lineWidth: 1) |
|
| 637 | 628 |
) |
| 638 | 629 |
} |
| 639 | 630 |
|
| 631 |
+ private func chartLegendEntries( |
|
| 632 |
+ powerSeries: SeriesData, |
|
| 633 |
+ energySeries: SeriesData, |
|
| 634 |
+ voltageSeries: SeriesData, |
|
| 635 |
+ currentSeries: SeriesData, |
|
| 636 |
+ temperatureSeries: SeriesData |
|
| 637 |
+ ) -> [SeriesLegendEntry] {
|
|
| 638 |
+ var entries: [SeriesLegendEntry] = [] |
|
| 639 |
+ |
|
| 640 |
+ if displayPower {
|
|
| 641 |
+ entries.append(contentsOf: legendEntry(for: powerSeries)) |
|
| 642 |
+ } else if displayEnergy {
|
|
| 643 |
+ entries.append(contentsOf: legendEntry(for: energySeries)) |
|
| 644 |
+ } else {
|
|
| 645 |
+ if displayVoltage {
|
|
| 646 |
+ entries.append(contentsOf: legendEntry(for: voltageSeries)) |
|
| 647 |
+ } |
|
| 648 |
+ if displayCurrent {
|
|
| 649 |
+ entries.append(contentsOf: legendEntry(for: currentSeries)) |
|
| 650 |
+ } |
|
| 651 |
+ } |
|
| 652 |
+ |
|
| 653 |
+ if displayTemperature {
|
|
| 654 |
+ entries.append(contentsOf: legendEntry(for: temperatureSeries)) |
|
| 655 |
+ } |
|
| 656 |
+ |
|
| 657 |
+ return entries |
|
| 658 |
+ } |
|
| 659 |
+ |
|
| 660 |
+ private func legendEntry(for series: SeriesData) -> [SeriesLegendEntry] {
|
|
| 661 |
+ let samples = series.samplePoints |
|
| 662 |
+ guard |
|
| 663 |
+ let minimumValue = samples.map(\.value).min(), |
|
| 664 |
+ let maximumValue = samples.map(\.value).max(), |
|
| 665 |
+ let lastValue = samples.last?.value |
|
| 666 |
+ else {
|
|
| 667 |
+ return [] |
|
| 668 |
+ } |
|
| 669 |
+ |
|
| 670 |
+ let averageValue = samples.reduce(0) { $0 + $1.value } / Double(samples.count)
|
|
| 671 |
+ |
|
| 672 |
+ return [ |
|
| 673 |
+ SeriesLegendEntry( |
|
| 674 |
+ id: series.kind, |
|
| 675 |
+ name: series.kind.displayName, |
|
| 676 |
+ tint: series.kind.tint, |
|
| 677 |
+ minimumText: legendValueText(minimumValue, for: series.kind), |
|
| 678 |
+ averageText: legendValueText(averageValue, for: series.kind), |
|
| 679 |
+ maximumText: legendValueText(maximumValue, for: series.kind), |
|
| 680 |
+ lastText: legendValueText(lastValue, for: series.kind) |
|
| 681 |
+ ) |
|
| 682 |
+ ] |
|
| 683 |
+ } |
|
| 684 |
+ |
|
| 685 |
+ @ViewBuilder |
|
| 686 |
+ private func chartLegend(entries: [SeriesLegendEntry]) -> some View {
|
|
| 687 |
+ if !entries.isEmpty {
|
|
| 688 |
+ let nameWidth: CGFloat = compactLayout ? 88 : (isLargeDisplay ? 128 : 108) |
|
| 689 |
+ let valueWidth: CGFloat = compactLayout ? 78 : (isLargeDisplay ? 108 : 92) |
|
| 690 |
+ |
|
| 691 |
+ ScrollView(.horizontal, showsIndicators: false) {
|
|
| 692 |
+ VStack(alignment: .leading, spacing: compactLayout ? 5 : 7) {
|
|
| 693 |
+ HStack(spacing: compactLayout ? 8 : 10) {
|
|
| 694 |
+ legendHeaderText("Measurement", width: nameWidth, alignment: .leading)
|
|
| 695 |
+ legendHeaderText("Min", width: valueWidth)
|
|
| 696 |
+ legendHeaderText("Avg", width: valueWidth)
|
|
| 697 |
+ legendHeaderText("Max", width: valueWidth)
|
|
| 698 |
+ legendHeaderText("Last", width: valueWidth)
|
|
| 699 |
+ } |
|
| 700 |
+ |
|
| 701 |
+ ForEach(entries) { entry in
|
|
| 702 |
+ HStack(spacing: compactLayout ? 8 : 10) {
|
|
| 703 |
+ HStack(spacing: 6) {
|
|
| 704 |
+ Circle() |
|
| 705 |
+ .fill(entry.tint) |
|
| 706 |
+ .frame(width: compactLayout ? 7 : 8, height: compactLayout ? 7 : 8) |
|
| 707 |
+ |
|
| 708 |
+ Text(entry.name) |
|
| 709 |
+ .lineLimit(1) |
|
| 710 |
+ .minimumScaleFactor(0.82) |
|
| 711 |
+ } |
|
| 712 |
+ .frame(width: nameWidth, alignment: .leading) |
|
| 713 |
+ |
|
| 714 |
+ legendValueText(entry.minimumText, width: valueWidth) |
|
| 715 |
+ legendValueText(entry.averageText, width: valueWidth) |
|
| 716 |
+ legendValueText(entry.maximumText, width: valueWidth) |
|
| 717 |
+ legendValueText(entry.lastText, width: valueWidth) |
|
| 718 |
+ } |
|
| 719 |
+ } |
|
| 720 |
+ } |
|
| 721 |
+ .padding(.horizontal, compactLayout ? 10 : 12) |
|
| 722 |
+ .padding(.vertical, compactLayout ? 8 : 10) |
|
| 723 |
+ } |
|
| 724 |
+ .background( |
|
| 725 |
+ RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous) |
|
| 726 |
+ .fill(Color.primary.opacity(0.045)) |
|
| 727 |
+ ) |
|
| 728 |
+ .overlay( |
|
| 729 |
+ RoundedRectangle(cornerRadius: compactLayout ? 14 : 16, style: .continuous) |
|
| 730 |
+ .stroke(Color.secondary.opacity(0.14), lineWidth: 1) |
|
| 731 |
+ ) |
|
| 732 |
+ } |
|
| 733 |
+ } |
|
| 734 |
+ |
|
| 735 |
+ private func legendHeaderText( |
|
| 736 |
+ _ text: String, |
|
| 737 |
+ width: CGFloat, |
|
| 738 |
+ alignment: Alignment = .trailing |
|
| 739 |
+ ) -> some View {
|
|
| 740 |
+ Text(text) |
|
| 741 |
+ .font((compactLayout ? Font.caption2 : .caption).weight(.semibold)) |
|
| 742 |
+ .foregroundColor(.secondary) |
|
| 743 |
+ .textCase(.uppercase) |
|
| 744 |
+ .lineLimit(1) |
|
| 745 |
+ .frame(width: width, alignment: alignment) |
|
| 746 |
+ } |
|
| 747 |
+ |
|
| 748 |
+ private func legendValueText( |
|
| 749 |
+ _ text: String, |
|
| 750 |
+ width: CGFloat |
|
| 751 |
+ ) -> some View {
|
|
| 752 |
+ Text(text) |
|
| 753 |
+ .font((compactLayout ? Font.caption2 : .caption).weight(.semibold)) |
|
| 754 |
+ .monospacedDigit() |
|
| 755 |
+ .lineLimit(1) |
|
| 756 |
+ .minimumScaleFactor(0.78) |
|
| 757 |
+ .frame(width: width, alignment: .trailing) |
|
| 758 |
+ } |
|
| 759 |
+ |
|
| 760 |
+ private func legendValueText( |
|
| 761 |
+ _ value: Double, |
|
| 762 |
+ for kind: SeriesKind |
|
| 763 |
+ ) -> String {
|
|
| 764 |
+ let decimalDigits: Int |
|
| 765 |
+ switch kind {
|
|
| 766 |
+ case .power: |
|
| 767 |
+ decimalDigits = 2 |
|
| 768 |
+ case .energy, .voltage, .current: |
|
| 769 |
+ decimalDigits = 3 |
|
| 770 |
+ case .temperature: |
|
| 771 |
+ decimalDigits = 1 |
|
| 772 |
+ } |
|
| 773 |
+ |
|
| 774 |
+ let formattedValue = value.format(decimalDigits: decimalDigits) |
|
| 775 |
+ let unit = measurementUnit(for: kind) |
|
| 776 |
+ guard !unit.isEmpty else { return formattedValue }
|
|
| 777 |
+ |
|
| 778 |
+ if kind == .temperature {
|
|
| 779 |
+ return "\(formattedValue)\(unit)" |
|
| 780 |
+ } |
|
| 781 |
+ return "\(formattedValue) \(unit)" |
|
| 782 |
+ } |
|
| 783 |
+ |
|
| 640 | 784 |
private func seriesToggleRow(condensedLayout: Bool) -> some View {
|
| 641 | 785 |
HStack(spacing: condensedLayout ? 6 : 8) {
|
| 642 | 786 |
seriesToggleButton(title: "Voltage", isOn: displayVoltage, condensedLayout: condensedLayout) {
|
@@ -2800,6 +2944,8 @@ private struct TimeRangeSelectorView: View {
|
||
| 2800 | 2944 |
ForEach(Array(labels.enumerated()), id: \.offset) { item in
|
| 2801 | 2945 |
let labelIndex = item.offset + 1 |
| 2802 | 2946 |
let centerX = xPosition(for: labelIndex, totalLabels: labelCount, width: geometry.size.width) |
| 2947 |
+ let halfWidth = labelWidth / 2 |
|
| 2948 |
+ let clampedX = min(max(centerX, halfWidth), geometry.size.width - halfWidth) |
|
| 2803 | 2949 |
|
| 2804 | 2950 |
Text(item.element) |
| 2805 | 2951 |
.font(axisLabelFont) |
@@ -2808,7 +2954,7 @@ private struct TimeRangeSelectorView: View {
|
||
| 2808 | 2954 |
.minimumScaleFactor(0.74) |
| 2809 | 2955 |
.frame(width: labelWidth) |
| 2810 | 2956 |
.position( |
| 2811 |
- x: centerX, |
|
| 2957 |
+ x: clampedX, |
|
| 2812 | 2958 |
y: geometry.size.height * 0.66 |
| 2813 | 2959 |
) |
| 2814 | 2960 |
} |