GlucoseSectorChart.swift 22 KB

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