Showing 1 changed files with 255 additions and 109 deletions
+255 -109
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -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
                 }