AGPGraphView.swift 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. // LoopFollow
  2. // AGPGraphView.swift
  3. import Charts
  4. import SwiftUI
  5. struct AGPGraphView: View {
  6. let agpData: [AGPDataPoint]
  7. private enum Percentile: String, CaseIterable, Plottable {
  8. case p5 = "5th"
  9. case p25 = "25th"
  10. case median = "Median"
  11. case p75 = "75th"
  12. case p95 = "95th"
  13. var color: Color {
  14. switch self {
  15. case .p5, .p95: return Color(.systemGray).opacity(0.6)
  16. case .p25, .p75: return Color(.systemBlue).opacity(0.7)
  17. case .median: return Color(.systemBlue)
  18. }
  19. }
  20. var lineWidth: CGFloat {
  21. self == .median ? 3 : 1.5
  22. }
  23. }
  24. private struct Point: Identifiable {
  25. let percentile: Percentile
  26. let hour: Double
  27. let value: Double
  28. var id: String { "\(percentile.rawValue)-\(hour)" }
  29. }
  30. private var points: [Point] {
  31. let sortedData = agpData.sorted { $0.timeOfDay < $1.timeOfDay }
  32. return sortedData.flatMap { dp -> [Point] in
  33. let hour = Double(dp.timeOfDay) / 60.0
  34. return [
  35. Point(percentile: .p5, hour: hour, value: dp.p5),
  36. Point(percentile: .p25, hour: hour, value: dp.p25),
  37. Point(percentile: .median, hour: hour, value: dp.p50),
  38. Point(percentile: .p75, hour: hour, value: dp.p75),
  39. Point(percentile: .p95, hour: hour, value: dp.p95),
  40. ]
  41. }
  42. }
  43. private var activeThresholds: (low: Double, high: Double) {
  44. UnitSettingsStore.shared.effectiveThresholds()
  45. }
  46. private var lowThresholdLabel: String {
  47. Localizer.toDisplayUnits(String(activeThresholds.low))
  48. }
  49. private var highThresholdLabel: String {
  50. Localizer.toDisplayUnits(String(activeThresholds.high))
  51. }
  52. private var plottedMinValue: Double {
  53. min(points.map(\.value).min() ?? activeThresholds.low, activeThresholds.low)
  54. }
  55. private var plottedMaxValue: Double {
  56. max(points.map(\.value).max() ?? activeThresholds.high, activeThresholds.high)
  57. }
  58. private var yDomain: ClosedRange<Double> {
  59. let span = max(plottedMaxValue - plottedMinValue, 1)
  60. let topPadding = max(span * 0.05, 1)
  61. return plottedMinValue ... (plottedMaxValue + topPadding)
  62. }
  63. private var yAxisEndpoints: [Double] {
  64. if abs(plottedMaxValue - plottedMinValue) < 0.0001 {
  65. return [plottedMinValue]
  66. }
  67. return [plottedMinValue, plottedMaxValue]
  68. }
  69. var body: some View {
  70. Chart {
  71. RuleMark(y: .value("lowBoundary", activeThresholds.low))
  72. .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
  73. .annotation(position: .leading, alignment: .leading) {
  74. Text(lowThresholdLabel)
  75. .font(.caption2)
  76. }
  77. RuleMark(y: .value("highBoundary", activeThresholds.high))
  78. .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4]))
  79. .annotation(position: .leading, alignment: .leading) {
  80. Text(highThresholdLabel)
  81. .font(.caption2)
  82. }
  83. ForEach(points) { point in
  84. LineMark(
  85. x: .value("hour", point.hour),
  86. y: .value("bg", point.value)
  87. )
  88. .foregroundStyle(by: .value("percentile", point.percentile))
  89. .lineStyle(StrokeStyle(lineWidth: point.percentile.lineWidth))
  90. .interpolationMethod(.linear)
  91. }
  92. }
  93. .chartForegroundStyleScale(
  94. domain: Percentile.allCases,
  95. range: Percentile.allCases.map(\.color)
  96. )
  97. .chartLegend(.hidden)
  98. .chartXScale(domain: 0 ... 24)
  99. .chartYScale(domain: yDomain)
  100. .chartXAxis {
  101. AxisMarks(position: .bottom, values: Array(stride(from: 0, through: 24, by: 3))) { value in
  102. AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5))
  103. .foregroundStyle(Color(.label).opacity(0.3))
  104. AxisTick()
  105. AxisValueLabel {
  106. if let hour = value.as(Int.self) {
  107. Text("\(hour)")
  108. }
  109. }
  110. }
  111. }
  112. .chartYAxis {
  113. AxisMarks(position: .leading, values: yAxisEndpoints) { value in
  114. AxisValueLabel {
  115. if let raw = value.as(Double.self) {
  116. Text(Localizer.toDisplayUnits(String(raw)))
  117. }
  118. }
  119. }
  120. }
  121. .allowsHitTesting(false)
  122. .background(Color(.systemBackground))
  123. }
  124. }