GlucoseSectorChart.swift 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import Charts
  2. import CoreData
  3. import SwiftDate
  4. import SwiftUI
  5. struct GlucoseSectorChart: 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. // Calculate total number of glucose readings
  24. let total = Decimal(glucose.count)
  25. // Count readings between high limit and 250 mg/dL (high)
  26. let high = glucose.filter { $0.glucose > Int(highLimit) }.count
  27. // Count readings between low limit and 140 mg/dL (tight control)
  28. let tight = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= 140 }.count
  29. // Count readings between 140 and high limit (normal range)
  30. let normal = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= Int(highLimit) }.count
  31. // Count readings between 54 and low limit (low)
  32. let low = glucose.filter { $0.glucose < Int(lowLimit) }.count
  33. let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
  34. let sumReadings = justGlucoseArray.reduce(0, +)
  35. let glucoseAverage = Decimal(sumReadings) / total
  36. let medianGlucose = StatChartUtils.medianCalculation(array: justGlucoseArray)
  37. let lowPercentage = Decimal(low) / total * 100
  38. let tightPercentage = Decimal(tight) / total * 100
  39. let inRangePercentage = Decimal(normal) / total * 100
  40. let highPercentage = Decimal(high) / total * 100
  41. VStack(alignment: .leading, spacing: 10) {
  42. VStack(alignment: .leading, spacing: 5) {
  43. Text("\(formatValue(lowLimit))-\(formatValue(highLimit))").font(.subheadline).foregroundStyle(Color.secondary)
  44. Text(inRangePercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
  45. .foregroundStyle(Color.loopGreen)
  46. }
  47. VStack(alignment: .leading, spacing: 5) {
  48. Text("\(formatValue(lowLimit))-\(formatValue(140))").font(.subheadline).foregroundStyle(Color.secondary)
  49. Text(tightPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
  50. .foregroundStyle(Color.green)
  51. }
  52. }.padding(.leading, 5)
  53. VStack(alignment: .leading, spacing: 10) {
  54. VStack(alignment: .leading, spacing: 5) {
  55. Text("> \(formatValue(highLimit))").font(.subheadline).foregroundStyle(Color.secondary)
  56. Text(highPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
  57. .foregroundStyle(Color.orange)
  58. }
  59. VStack(alignment: .leading, spacing: 5) {
  60. Text("< \(formatValue(lowLimit))").font(.subheadline).foregroundStyle(Color.secondary)
  61. Text(lowPercentage.formatted(.number.grouping(.never).rounded().precision(.fractionLength(1))) + "%")
  62. .foregroundStyle(Color.loopRed)
  63. }
  64. }
  65. VStack(alignment: .leading, spacing: 10) {
  66. VStack(alignment: .leading, spacing: 5) {
  67. Text("Average").font(.subheadline).foregroundStyle(Color.secondary)
  68. Text(
  69. units == .mgdL ? glucoseAverage
  70. .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : glucoseAverage.asMmolL
  71. .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
  72. )
  73. }
  74. VStack(alignment: .leading, spacing: 5) {
  75. Text("Median").font(.subheadline).foregroundStyle(Color.secondary)
  76. Text(
  77. units == .mgdL ? medianGlucose
  78. .formatted(.number.grouping(.never).rounded().precision(.fractionLength(0))) : medianGlucose.asMmolL
  79. .formatted(.number.grouping(.never).rounded().precision(.fractionLength(1)))
  80. )
  81. }
  82. }
  83. Chart {
  84. ForEach(rangeData, id: \.range) { data in
  85. SectorMark(
  86. angle: .value("Percentage", data.count),
  87. innerRadius: .ratio(0.618),
  88. outerRadius: selectedRange == data.range ? 100 : 80,
  89. angularInset: 1.5
  90. )
  91. .foregroundStyle(data.color)
  92. }
  93. }
  94. .chartAngleSelection(value: $selectedCount)
  95. .frame(height: 100)
  96. }
  97. .onChange(of: selectedCount) { _, newValue in
  98. if let newValue {
  99. withAnimation {
  100. getSelectedRange(value: newValue)
  101. }
  102. } else {
  103. withAnimation {
  104. selectedRange = nil
  105. }
  106. }
  107. }
  108. .overlay(alignment: .top) {
  109. if let selectedRange {
  110. let data = getDetailedData(for: selectedRange)
  111. RangeDetailPopover(data: data)
  112. .transition(.scale.combined(with: .opacity))
  113. .offset(y: -150) // TODO: make this dynamic
  114. }
  115. }
  116. }
  117. /// Calculates statistics about glucose ranges and returns data for the sector chart
  118. ///
  119. /// This computed property processes glucose readings and categorizes them into high, in-range, and low ranges.
  120. /// For each range, it calculates:
  121. /// - The count of readings in that range
  122. /// - The percentage of total readings
  123. /// - The associated color for visualization
  124. ///
  125. /// - Returns: An array of tuples containing range data, where each tuple has:
  126. /// - range: The glucose range category (high, in-range, or low)
  127. /// - count: Number of readings in that range
  128. /// - percentage: Percentage of total readings in that range
  129. /// - color: Color used to represent that range in the chart
  130. private var rangeData: [(range: GlucoseRange, count: Int, percentage: Decimal, color: Color)] {
  131. let total = glucose.count
  132. // Return empty array if no glucose readings available
  133. guard total > 0 else { return [] }
  134. // Count readings above high limit
  135. let highCount = glucose.filter { $0.glucose > Int(highLimit) }.count
  136. // Count readings below low limit
  137. let lowCount = glucose.filter { $0.glucose < Int(lowLimit) }.count
  138. // Calculate in-range readings by subtracting high and low counts from total
  139. let inRangeCount = total - highCount - lowCount
  140. // Return array of tuples with range data
  141. return [
  142. (.high, highCount, Decimal(highCount) / Decimal(total) * 100, .orange),
  143. (.inRange, inRangeCount, Decimal(inRangeCount) / Decimal(total) * 100, .green),
  144. (.low, lowCount, Decimal(lowCount) / Decimal(total) * 100, .red)
  145. ]
  146. }
  147. /// Determines which glucose range was selected based on a cumulative value
  148. ///
  149. /// This function takes a value representing a point in the cumulative total of glucose readings
  150. /// and determines which range (high, in-range, or low) that point falls into.
  151. /// It updates the selectedRange state variable when the appropriate range is found.
  152. ///
  153. /// - Parameter value: An integer representing a point in the cumulative total of readings
  154. private func getSelectedRange(value: Int) {
  155. // Keep track of running total as we check each range
  156. var cumulativeTotal = 0
  157. // Find first range where value falls within its cumulative count
  158. _ = rangeData.first { data in
  159. cumulativeTotal += data.count
  160. if value <= cumulativeTotal {
  161. selectedRange = data.range
  162. return true
  163. }
  164. return false
  165. }
  166. }
  167. /// Gets detailed statistics for a specific glucose range category
  168. ///
  169. /// This function calculates detailed statistics for a given glucose range (high, in-range, or low),
  170. /// breaking down the readings into subcategories and calculating percentages.
  171. ///
  172. /// - Parameter range: The glucose range category to analyze
  173. /// - Returns: A RangeDetail object containing the title, color and detailed statistics
  174. private func getDetailedData(for range: GlucoseRange) -> RangeDetail {
  175. let total = Decimal(glucose.count)
  176. switch range {
  177. case .high:
  178. let veryHigh = glucose.filter { $0.glucose > 250 }.count
  179. let high = glucose.filter { $0.glucose > Int(highLimit) && $0.glucose <= 250 }.count
  180. let highGlucoseValues = glucose.filter { $0.glucose > Int(highLimit) }
  181. let highGlucoseValuesAsInt = highGlucoseValues.map { Int($0.glucose) }
  182. let (average, median, standardDeviation) = calculateDetailedStatistics(for: highGlucoseValuesAsInt)
  183. return RangeDetail(
  184. title: "High Glucose",
  185. color: .orange,
  186. items: [
  187. ("Very High (>\(formatValue(250)))", formatPercentage(Decimal(veryHigh) / total * 100)),
  188. ("High (\(formatValue(highLimit))-\(formatValue(250)))", formatPercentage(Decimal(high) / total * 100)),
  189. ("Average", formatValue(average)),
  190. ("Median", formatValue(median)),
  191. ("SD", formatSD(standardDeviation))
  192. ]
  193. )
  194. case .inRange:
  195. let tight = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= 140 }.count
  196. let glucoseValues = glucose.filter { $0.glucose >= Int(lowLimit) && $0.glucose <= Int(highLimit) }
  197. let glucoseValuesAsInt = glucoseValues.map { Int($0.glucose) }
  198. let (average, median, standardDeviation) = calculateDetailedStatistics(for: glucoseValuesAsInt)
  199. return RangeDetail(
  200. title: "In Range",
  201. color: .green,
  202. items: [
  203. (
  204. "Normal (\(formatValue(lowLimit))-\(formatValue(highLimit)))",
  205. formatPercentage(Decimal(glucoseValues.count) / total * 100)
  206. ),
  207. ("Tight (\(formatValue(lowLimit))-\(formatValue(140)))", formatPercentage(Decimal(tight) / total * 100)),
  208. ("Average", formatValue(average)),
  209. ("Median", formatValue(median)),
  210. ("SD", formatSD(standardDeviation))
  211. ]
  212. )
  213. case .low:
  214. let veryLow = glucose.filter { $0.glucose <= 54 }.count
  215. let low = glucose.filter { $0.glucose > 54 && $0.glucose < Int(lowLimit) }.count
  216. let lowGlucoseValues = glucose.filter { $0.glucose < Int(lowLimit) }
  217. let lowGlucoseValuesAsInt = lowGlucoseValues.map { Int($0.glucose) }
  218. let (average, median, standardDeviation) = calculateDetailedStatistics(for: lowGlucoseValuesAsInt)
  219. return RangeDetail(
  220. title: "Low Glucose",
  221. color: .red,
  222. items: [
  223. ("Low (\(formatValue(54))-\(formatValue(lowLimit)))", formatPercentage(Decimal(low) / total * 100)),
  224. ("Very Low (<\(formatValue(54)))", formatPercentage(Decimal(veryLow) / total * 100)),
  225. ("Average", formatValue(average)),
  226. ("Median", formatValue(median)),
  227. ("SD", formatSD(standardDeviation))
  228. ]
  229. )
  230. }
  231. }
  232. /// Formats a percentage value to a string with one decimal place.
  233. /// - Parameter value: A decimal value representing the percentage.
  234. /// - Returns: A formatted percentage string
  235. private func formatPercentage(_ value: Decimal) -> String {
  236. let formatter = NumberFormatter()
  237. formatter.numberStyle = .percent
  238. formatter.maximumFractionDigits = 1
  239. return formatter.string(from: NSDecimalNumber(decimal: value / 100)) ?? "0%"
  240. }
  241. /// Calculates statistical values for a given array of glucose readings.
  242. /// - Parameter values: An array of glucose readings as integers.
  243. /// - Returns: A tuple containing the average, median, and standard deviation.
  244. private func calculateDetailedStatistics(for values: [Int]) -> (Decimal, Decimal, Double) {
  245. guard !values.isEmpty else { return (0, 0, 0) }
  246. let total = values.reduce(0, +)
  247. let average = Decimal(total / values.count)
  248. let median = Decimal(StatChartUtils.medianCalculation(array: values))
  249. let sumOfSquares = values.reduce(0.0) { sum, value in
  250. sum + pow(Double(value) - Double(average), 2)
  251. }
  252. let standardDeviation = sqrt(sumOfSquares / Double(values.count))
  253. return (average, median, standardDeviation)
  254. }
  255. /// Formats the standard deviation value based on glucose units.
  256. /// - Parameter sd: The standard deviation as a Double.
  257. /// - Returns: A formatted string representing the standard deviation.
  258. private func formatSD(_ sd: Double) -> String {
  259. units == .mgdL ? sd.formatted(
  260. .number.grouping(.never).rounded().precision(.fractionLength(0))
  261. ) : sd.formattedAsMmolL
  262. }
  263. /// Formats a glucose value based on the current units.
  264. /// - Parameter value: A decimal value representing the glucose level.
  265. /// - Returns: A formatted string of the glucose value.
  266. private func formatValue(_ value: Decimal) -> String {
  267. units == .mgdL ? value.description : value.formattedAsMmolL
  268. }
  269. }
  270. /// Represents details about a specific glucose range category including title, color and percentage breakdowns
  271. private struct RangeDetail {
  272. /// The title of this range category (e.g. "High Glucose", "In Range", "Low Glucose")
  273. let title: String
  274. /// The color used to represent this range in the UI
  275. let color: Color
  276. /// Array of tuples containing label and percentage for each sub-range
  277. let items: [(label: String, value: String)]
  278. }
  279. /// A popover view that displays detailed breakdown of glucose percentages for a range category
  280. private struct RangeDetailPopover: View {
  281. let data: RangeDetail
  282. @Environment(\.colorScheme) var colorScheme
  283. var body: some View {
  284. VStack(alignment: .leading, spacing: 8) {
  285. Text(data.title)
  286. .font(.subheadline)
  287. .fontWeight(.bold)
  288. .foregroundStyle(data.color)
  289. .padding(.bottom, 4)
  290. ForEach(Array(data.items.enumerated()), id: \..offset) { index, item in
  291. if index < 2 {
  292. HStack {
  293. Text(item.label)
  294. Text(item.value).bold()
  295. }
  296. .font(.footnote)
  297. }
  298. }
  299. HStack(spacing: 20) {
  300. ForEach(Array(data.items.enumerated()), id: \..offset) { index, item in
  301. if index > 1 {
  302. VStack(alignment: .leading, spacing: 5) {
  303. Text(item.label)
  304. HStack {
  305. Text(item.value).bold()
  306. }
  307. }
  308. .font(.footnote)
  309. }
  310. }
  311. }
  312. }
  313. .padding(20)
  314. .background {
  315. RoundedRectangle(cornerRadius: 10)
  316. .fill(colorScheme == .dark ? Color.bgDarkBlue.opacity(0.9) : Color.white.opacity(0.95))
  317. .shadow(color: Color.secondary, radius: 2)
  318. .overlay(
  319. RoundedRectangle(cornerRadius: 4)
  320. .stroke(data.color, lineWidth: 2)
  321. )
  322. }
  323. }
  324. }