ChartsView.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import Charts
  2. import CoreData
  3. import SwiftDate
  4. import SwiftUI
  5. struct ChartsView: View {
  6. let highLimit: Decimal
  7. let lowLimit: Decimal
  8. let units: GlucoseUnits
  9. let hbA1cDisplayUnit: HbA1cDisplayUnit
  10. let timeInRangeChartStyle: TimeInRangeChartStyle
  11. let glucose: [GlucoseStored]
  12. @State var headline: Color = .secondary
  13. private var conversionFactor: Decimal {
  14. units == .mmolL ? GlucoseUnits.exchangeRate : 1
  15. }
  16. /// Converts a mmol/L clinical threshold to the equivalent Int16 mg/dL bucket
  17. /// used by `GlucoseStored.glucose`. Routes through the canonical `Double.asMgdL`
  18. /// helper so the conversion stays consistent with the rest of the app.
  19. private func thresholdMgdL(_ mmol: Double) -> Int16 {
  20. Int16(truncating: mmol.asMgdL as NSDecimalNumber)
  21. }
  22. var body: some View {
  23. glucoseChart
  24. Rectangle().fill(.cyan.opacity(0.2)).frame(maxHeight: 3)
  25. if timeInRangeChartStyle == .horizontal {
  26. VStack {
  27. tirChartLaying
  28. Rectangle().fill(.cyan.opacity(0.2)).frame(maxHeight: 3)
  29. groupedGlucoseStatsLaying
  30. }
  31. } else {
  32. HStack(spacing: 20) {
  33. tirChartStanding
  34. groupedGlucoseStatsStanding
  35. }.padding(.horizontal, 10)
  36. }
  37. }
  38. init(
  39. highLimit: Decimal,
  40. lowLimit: Decimal,
  41. units: GlucoseUnits,
  42. hbA1cDisplayUnit: HbA1cDisplayUnit,
  43. timeInRangeChartStyle: TimeInRangeChartStyle,
  44. glucose: [GlucoseStored]
  45. ) {
  46. self.highLimit = highLimit
  47. self.lowLimit = lowLimit
  48. self.units = units
  49. self.hbA1cDisplayUnit = hbA1cDisplayUnit
  50. self.timeInRangeChartStyle = timeInRangeChartStyle
  51. self.glucose = glucose
  52. }
  53. var glucoseChart: some View {
  54. let low = lowLimit * conversionFactor
  55. let high = highLimit * conversionFactor
  56. let count = glucose.count
  57. // The symbol size when fewer readings are larger
  58. let size: CGFloat = count < 20 ? 50 : count < 50 ? 35 : count > 2000 ? 5 : 15
  59. return Chart {
  60. ForEach(glucose) { item in
  61. if item.glucose > Int(highLimit) {
  62. PointMark(
  63. x: .value("Time", item.date ?? Date(), unit: .second),
  64. y: .value("Value", Decimal(item.glucose) * conversionFactor)
  65. ).foregroundStyle(Color.orange.gradient).symbolSize(size).interpolationMethod(.cardinal)
  66. } else if item.glucose < Int(lowLimit) {
  67. PointMark(
  68. x: .value("Time", item.date ?? Date(), unit: .second),
  69. y: .value("Value", Decimal(item.glucose) * conversionFactor)
  70. ).foregroundStyle(Color.red.gradient).symbolSize(size).interpolationMethod(.cardinal)
  71. } else {
  72. PointMark(
  73. x: .value("Time", item.date ?? Date(), unit: .second),
  74. y: .value("Value", Decimal(item.glucose) * conversionFactor)
  75. ).foregroundStyle(Color.green.gradient).symbolSize(size).interpolationMethod(.cardinal)
  76. }
  77. }
  78. }
  79. .chartYAxis {
  80. AxisMarks(
  81. values: [
  82. 0,
  83. low,
  84. high,
  85. units == .mmolL ? 15 : 270
  86. ]
  87. )
  88. }
  89. }
  90. var tirChartLaying: some View {
  91. let fetched = tir()
  92. let low = lowLimit * conversionFactor
  93. let high = highLimit * conversionFactor
  94. let fraction = units == .mgdL ? 0 : 1
  95. let data: [ShapeModel] = [
  96. .init(
  97. type: String(
  98. localized:
  99. "Low",
  100. comment: ""
  101. ) + " (<\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))",
  102. percent: fetched[0].decimal
  103. ),
  104. .init(type: String(localized: "In Range", comment: ""), percent: fetched[1].decimal),
  105. .init(
  106. type: String(
  107. localized:
  108. "High",
  109. comment: ""
  110. ) + " (>\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))",
  111. percent: fetched[2].decimal
  112. )
  113. ]
  114. return VStack {
  115. Chart(data) { shape in
  116. BarMark(
  117. x: .value("TIR", shape.percent)
  118. )
  119. .foregroundStyle(by: .value("Group", shape.type))
  120. .annotation(position: .top, alignment: .center) {
  121. Text(
  122. "\(shape.percent, format: .number.precision(.fractionLength(0 ... 1))) %"
  123. ).font(.footnote).foregroundColor(.secondary)
  124. }
  125. }
  126. .chartXAxis(.hidden)
  127. .chartForegroundStyleScale([
  128. String(
  129. localized:
  130. "Low",
  131. comment: ""
  132. ) + " (<\(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))": .red,
  133. String(localized: "In Range", comment: ""): .green,
  134. String(
  135. localized:
  136. "High",
  137. comment: ""
  138. ) + " (>\(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))": .orange
  139. ]).frame(maxHeight: 25)
  140. }
  141. .frame(maxWidth: .infinity, alignment: .center) // Align the entire VStack to center
  142. .padding(.horizontal, 5)
  143. }
  144. var tirChartStanding: some View {
  145. let fetched = tir()
  146. let low = lowLimit * conversionFactor
  147. let high = highLimit * conversionFactor
  148. let fraction = units == .mmolL ? 1 : 0
  149. let data: [ShapeModel] = [
  150. .init(
  151. type: String(
  152. localized:
  153. "Low",
  154. comment: ""
  155. ) + " (< \(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))",
  156. percent: fetched[0].decimal
  157. ),
  158. .init(
  159. type: "\(low.formatted(.number.precision(.fractionLength(fraction)))) - \(high.formatted(.number.precision(.fractionLength(fraction))))",
  160. percent: fetched[1].decimal
  161. ),
  162. .init(
  163. type: String(
  164. localized:
  165. "High",
  166. comment: ""
  167. ) + " (> \(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))",
  168. percent: fetched[2].decimal
  169. )
  170. ]
  171. return Chart(data) { shape in
  172. BarMark(
  173. x: .value("Shape", shape.type),
  174. y: .value("Percentage", shape.percent)
  175. )
  176. .foregroundStyle(by: .value("Group", shape.type))
  177. .annotation(position: shape.percent > 19 ? .overlay : .automatic, alignment: .center) {
  178. Text(shape.percent == 0 ? "" : "\(shape.percent, format: .number.precision(.fractionLength(0 ... 1)))")
  179. }
  180. }
  181. .chartXAxis(.hidden)
  182. .chartYAxis {
  183. AxisMarks(
  184. format: Decimal.FormatStyle.Percent.percent.scale(1)
  185. )
  186. }
  187. .chartForegroundStyleScale([
  188. String(
  189. localized:
  190. "Low",
  191. comment: ""
  192. ) + " (< \(low.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))": .red,
  193. "\(low.formatted(.number.precision(.fractionLength(fraction)))) - \(high.formatted(.number.precision(.fractionLength(fraction))))": .green,
  194. String(
  195. localized:
  196. "High",
  197. comment: ""
  198. ) + " (> \(high.formatted(.number.grouping(.never).rounded().precision(.fractionLength(fraction)))))": .orange
  199. ])
  200. }
  201. var groupedGlucoseStatsStanding: some View {
  202. VStack(alignment: .leading, spacing: 15) {
  203. let mapGlucose = glucose.compactMap({ each in each.glucose })
  204. if !mapGlucose.isEmpty {
  205. let mapGlucoseAcuteLow = mapGlucose.filter({ $0 < thresholdMgdL(3.3) })
  206. let mapGlucoseHigh = mapGlucose.filter({ $0 > thresholdMgdL(11) })
  207. let mapGlucoseNormal = mapGlucose.filter({ $0 > thresholdMgdL(3.8) && $0 < thresholdMgdL(7.9) })
  208. HStack {
  209. let value = 100.0 * Double(mapGlucoseHigh.count) / Double(mapGlucose.count)
  210. Text(units == .mmolL ? "> 11 " : "> 198 ")
  211. .frame(width: 45, alignment: .leading)
  212. .foregroundColor(.secondary)
  213. Text("\(value.formatted(.number.precision(.fractionLength(0 ... 1)))) %")
  214. .frame(width: 45, alignment: .trailing)
  215. .foregroundColor(.orange)
  216. }.font(.caption)
  217. HStack {
  218. let value = 100.0 * Double(mapGlucoseNormal.count) / Double(mapGlucose.count)
  219. Text(units == .mmolL ? "3.9-7.8" : "70-140")
  220. .frame(width: 45, alignment: .leading)
  221. .foregroundColor(.secondary)
  222. Text("\(value.formatted(.number.precision(.fractionLength(0 ... 1)))) %")
  223. .frame(width: 45, alignment: .trailing)
  224. .foregroundColor(.green)
  225. }.font(.caption)
  226. HStack {
  227. let value = 100.0 * Double(mapGlucoseAcuteLow.count) / Double(mapGlucose.count)
  228. Text(units == .mmolL ? "< 3.3 " : "< 59 ")
  229. .frame(width: 45, alignment: .leading)
  230. .foregroundColor(.secondary)
  231. Text("\(value.formatted(.number.precision(.fractionLength(0 ... 1)))) %")
  232. .frame(width: 45, alignment: .trailing)
  233. .foregroundColor(.red)
  234. }.font(.caption)
  235. }
  236. }
  237. }
  238. var groupedGlucoseStatsLaying: some View {
  239. HStack {
  240. let mapGlucose = glucose.compactMap({ each in each.glucose })
  241. if !mapGlucose.isEmpty {
  242. let mapGlucoseLow = mapGlucose.filter({ $0 < thresholdMgdL(3.3) })
  243. let mapGlucoseNormal = mapGlucose.filter({ $0 > thresholdMgdL(3.8) && $0 < thresholdMgdL(7.9) })
  244. let mapGlucoseAcuteHigh = mapGlucose.filter({ $0 > thresholdMgdL(11) })
  245. Spacer()
  246. HStack {
  247. let value = 100.0 * Double(mapGlucoseLow.count) / Double(mapGlucose.count)
  248. Text(units == .mmolL ? "< 3.3" : "< 59").font(.caption2).foregroundColor(.secondary)
  249. Text(value.formatted(.number.precision(.fractionLength(0 ... 1)))).font(.caption)
  250. .foregroundColor(value == 0 ? .green : .red)
  251. Text("%").font(.caption)
  252. }
  253. Spacer()
  254. HStack {
  255. let value = 100.0 * Double(mapGlucoseNormal.count) / Double(mapGlucose.count)
  256. Text(units == .mmolL ? "3.9-7.8" : "70-140").foregroundColor(.secondary)
  257. Text(value.formatted(.number.precision(.fractionLength(0 ... 1)))).foregroundColor(.green)
  258. Text("%").foregroundColor(.secondary)
  259. }.font(.caption)
  260. Spacer()
  261. HStack {
  262. let value = 100.0 * Double(mapGlucoseAcuteHigh.count) / Double(mapGlucose.count)
  263. Text(units == .mmolL ? "> 11.0" : "> 198").font(.caption).foregroundColor(.secondary)
  264. Text(value.formatted(.number.precision(.fractionLength(0 ... 1)))).font(.caption)
  265. .foregroundColor(value == 0 ? .green : .orange)
  266. Text("%").font(.caption)
  267. }
  268. Spacer()
  269. }
  270. }
  271. }
  272. private func tir() -> [(decimal: Decimal, string: String)] {
  273. let hypoLimit = Int(lowLimit)
  274. let hyperLimit = Int(highLimit)
  275. let justGlucoseArray = glucose.compactMap({ each in Int(each.glucose as Int16) })
  276. let totalReadings = justGlucoseArray.count
  277. let hyperArray = glucose.filter({ $0.glucose > hyperLimit })
  278. let hyperReadings = hyperArray.compactMap({ each in each.glucose as Int16 }).count
  279. let hyperPercentage = Double(hyperReadings) / Double(totalReadings) * 100
  280. let hypoArray = glucose.filter({ $0.glucose < hypoLimit })
  281. let hypoReadings = hypoArray.compactMap({ each in each.glucose as Int16 }).count
  282. let hypoPercentage = Double(hypoReadings) / Double(totalReadings) * 100
  283. let tir = 100 - (hypoPercentage + hyperPercentage)
  284. var array: [(decimal: Decimal, string: String)] = []
  285. array.append((decimal: Decimal(hypoPercentage), string: "Low"))
  286. array.append((decimal: Decimal(tir), string: "NormaL"))
  287. array.append((decimal: Decimal(hyperPercentage), string: "High"))
  288. return array
  289. }
  290. }