GlucoseSectorChart.swift 17 KB

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