SectorChart.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. import Charts
  2. import CoreData
  3. import SwiftDate
  4. import SwiftUI
  5. struct SectorChart: View {
  6. let highLimit: Decimal
  7. let lowLimit: Decimal
  8. let units: GlucoseUnits
  9. let glucose: [GlucoseStored]
  10. @State private var selectedCount: Int?
  11. @State private var selectedRange: GlucoseRange?
  12. /// Represents the different ranges of glucose values that can be displayed in the sector chart
  13. /// - high: Above target range
  14. /// - inRange: Within target range
  15. /// - low: Below target range
  16. private enum GlucoseRange: String, Plottable {
  17. case high = "High"
  18. case inRange = "In Range"
  19. case low = "Low"
  20. }
  21. var body: some View {
  22. HStack(alignment: .center, spacing: 20) {
  23. Chart {
  24. ForEach(rangeData, id: \.range) { data in
  25. SectorMark(
  26. angle: .value("Percentage", data.count),
  27. innerRadius: .ratio(0.618),
  28. outerRadius: selectedRange == data.range ? 100 : 80,
  29. angularInset: 1.5
  30. )
  31. .cornerRadius(3)
  32. .foregroundStyle(data.color.gradient)
  33. .annotation(position: .overlay, alignment: .center, spacing: 0) {
  34. if data.percentage > 0 {
  35. Text("\(Int(data.percentage))%")
  36. .font(.callout)
  37. .foregroundStyle(.white)
  38. .fontWeight(.bold)
  39. }
  40. }
  41. }
  42. }
  43. .chartLegend(position: .bottom, spacing: 20)
  44. .chartAngleSelection(value: $selectedCount)
  45. .chartForegroundStyleScale([
  46. "High": Color.orange,
  47. "In Range": Color.green,
  48. "Low": Color.red
  49. ])
  50. .padding(.vertical)
  51. .frame(height: 250)
  52. }
  53. .onChange(of: selectedCount) { _, newValue in
  54. if let newValue {
  55. withAnimation {
  56. getSelectedRange(value: newValue)
  57. }
  58. } else {
  59. withAnimation {
  60. selectedRange = nil
  61. }
  62. }
  63. }
  64. .overlay(alignment: .top) {
  65. if let selectedRange {
  66. let data = getDetailedData(for: selectedRange)
  67. RangeDetailPopover(data: data)
  68. .transition(.scale.combined(with: .opacity))
  69. .offset(y: -90) // TODO: make this dynamic
  70. }
  71. }
  72. }
  73. /// Calculates statistics about glucose ranges and returns data for the sector chart
  74. ///
  75. /// This computed property processes glucose readings and categorizes them into high, in-range, and low ranges.
  76. /// For each range, it calculates:
  77. /// - The count of readings in that range
  78. /// - The percentage of total readings
  79. /// - The associated color for visualization
  80. ///
  81. /// - Returns: An array of tuples containing range data, where each tuple has:
  82. /// - range: The glucose range category (high, in-range, or low)
  83. /// - count: Number of readings in that range
  84. /// - percentage: Percentage of total readings in that range
  85. /// - color: Color used to represent that range in the chart
  86. private var rangeData: [(range: GlucoseRange, count: Int, percentage: Decimal, color: Color)] {
  87. let total = glucose.count
  88. // Return empty array if no glucose readings available
  89. guard total > 0 else { return [] }
  90. // Count readings above high limit
  91. let highCount = glucose.filter { $0.glucose > Int(highLimit) }.count
  92. // Count readings below low limit
  93. let lowCount = glucose.filter { $0.glucose < Int(lowLimit) }.count
  94. // Calculate in-range readings by subtracting high and low counts from total
  95. let inRangeCount = total - highCount - lowCount
  96. // Return array of tuples with range data
  97. return [
  98. (.high, highCount, Decimal(highCount) / Decimal(total) * 100, .orange),
  99. (.inRange, inRangeCount, Decimal(inRangeCount) / Decimal(total) * 100, .green),
  100. (.low, lowCount, Decimal(lowCount) / Decimal(total) * 100, .red)
  101. ]
  102. }
  103. /// Determines which glucose range was selected based on a cumulative value
  104. ///
  105. /// This function takes a value representing a point in the cumulative total of glucose readings
  106. /// and determines which range (high, in-range, or low) that point falls into.
  107. /// It updates the selectedRange state variable when the appropriate range is found.
  108. ///
  109. /// - Parameter value: An integer representing a point in the cumulative total of readings
  110. private func getSelectedRange(value: Int) {
  111. // Keep track of running total as we check each range
  112. var cumulativeTotal = 0
  113. // Find first range where value falls within its cumulative count
  114. _ = rangeData.first { data in
  115. cumulativeTotal += data.count
  116. if value <= cumulativeTotal {
  117. selectedRange = data.range
  118. return true
  119. }
  120. return false
  121. }
  122. }
  123. /// Gets detailed statistics for a specific glucose range category
  124. ///
  125. /// This function calculates detailed statistics for a given glucose range (high, in-range, or low),
  126. /// breaking down the readings into subcategories and calculating percentages.
  127. ///
  128. /// - Parameter range: The glucose range category to analyze
  129. /// - Returns: A RangeDetail object containing the title, color and detailed statistics
  130. private func getDetailedData(for range: GlucoseRange) -> RangeDetail {
  131. // Calculate total number of glucose readings
  132. let total = Decimal(glucose.count)
  133. switch range {
  134. case .high:
  135. // Count readings above 250 mg/dL (very high)
  136. let veryHigh = glucose.filter { $0.glucose > 250 }.count
  137. // Count readings between high limit and 250 mg/dL (high)
  138. let high = glucose.filter { $0.glucose > Int(highLimit) && $0.glucose <= 250 }.count
  139. // Format glucose values
  140. let highLimitTreshold = units == .mmolL ? Decimal(Int(highLimit)).asMmolL : highLimit
  141. let veryHighThreshold = units == .mmolL ? Decimal(250).asMmolL : 250
  142. return RangeDetail(
  143. title: "High Glucose",
  144. color: .orange,
  145. items: [
  146. ("Very High (>\(veryHighThreshold) \(units.rawValue))", Decimal(veryHigh) / total * 100),
  147. ("High (\(highLimitTreshold)-\(veryHighThreshold) \(units.rawValue))", Decimal(high) / total * 100)
  148. ]
  149. )
  150. case .inRange:
  151. // Count readings between low limit and 140 mg/dL (tight control)
  152. let tight = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= 140 }.count
  153. // Count readings between 140 and high limit (normal range)
  154. let normal = glucose.filter { $0.glucose > 140 && $0.glucose <= Int(highLimit) }.count
  155. // Format glucose values
  156. let lowLimitTreshold = units == .mmolL ? Decimal(Int(lowLimit)).asMmolL : lowLimit
  157. let highLimitTreshold = units == .mmolL ? Decimal(Int(highLimit)).asMmolL : highLimit
  158. let tightThresholdTreshold = units == .mmolL ? Decimal(140).asMmolL : 140
  159. return RangeDetail(
  160. title: "In Range",
  161. color: .green,
  162. items: [
  163. ("Tight (\(lowLimitTreshold)-\(tightThresholdTreshold) \(units.rawValue))", Decimal(tight) / total * 100),
  164. ("Normal (\(tightThresholdTreshold)-\(highLimitTreshold) \(units.rawValue))", Decimal(normal) / total * 100)
  165. ]
  166. )
  167. case .low:
  168. // Count readings below 54 mg/dL (very low/urgent low)
  169. let veryLow = glucose.filter { $0.glucose <= 54 }.count
  170. // Count readings between 54 and low limit (low)
  171. let low = glucose.filter { $0.glucose > 54 && $0.glucose < Int(lowLimit) }.count
  172. // Format glucose values
  173. let lowLimitTreshold = units == .mmolL ? Decimal(Int(lowLimit)).asMmolL : lowLimit
  174. let veryLowThresholdTreshold = units == .mmolL ? Decimal(54).asMmolL : 54
  175. return RangeDetail(
  176. title: "Low Glucose",
  177. color: .red,
  178. items: [
  179. ("Very Low (<\(veryLowThresholdTreshold) \(units.rawValue))", Decimal(veryLow) / total * 100),
  180. ("Low (\(veryLowThresholdTreshold)-\(lowLimitTreshold) \(units.rawValue))", Decimal(low) / total * 100)
  181. ]
  182. )
  183. }
  184. }
  185. }
  186. /// Represents details about a specific glucose range category including title, color and percentage breakdowns
  187. private struct RangeDetail {
  188. /// The title of this range category (e.g. "High Glucose", "In Range", "Low Glucose")
  189. let title: String
  190. /// The color used to represent this range in the UI
  191. let color: Color
  192. /// Array of tuples containing label and percentage for each sub-range
  193. let items: [(label: String, percentage: Decimal)]
  194. }
  195. /// A popover view that displays detailed breakdown of glucose percentages for a range category
  196. private struct RangeDetailPopover: View {
  197. let data: RangeDetail
  198. var body: some View {
  199. VStack(alignment: .leading, spacing: 4) {
  200. Text(data.title)
  201. .font(.subheadline)
  202. .fontWeight(.bold)
  203. ForEach(data.items, id: \.label) { item in
  204. HStack {
  205. Text(item.label)
  206. Spacer()
  207. Text(formatPercentage(item.percentage))
  208. }
  209. .font(.footnote)
  210. }
  211. }
  212. .foregroundStyle(.white)
  213. .padding(20)
  214. .background {
  215. RoundedRectangle(cornerRadius: 10)
  216. .fill(data.color.gradient)
  217. }
  218. }
  219. private func formatPercentage(_ value: Decimal) -> String {
  220. let formatter = NumberFormatter()
  221. formatter.numberStyle = .percent
  222. formatter.maximumFractionDigits = 1
  223. return formatter.string(from: NSDecimalNumber(decimal: value / 100)) ?? "0%"
  224. }
  225. }