| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421 |
- import Charts
- import SwiftUI
- enum GlucosePercentileType: String, Identifiable {
- case minimum = "Min"
- case percentile10 = "10th"
- case percentile25 = "25th"
- case median = "Median"
- case percentile75 = "75th"
- case percentile90 = "90th"
- case maximum = "Max"
- var id: String { rawValue }
- // Function to get the percentile value from a stats object
- func getValue(from stats: GlucoseDailyPercentileStats) -> Double {
- switch self {
- case .minimum: return stats.minimum
- case .percentile10: return stats.percentile10
- case .percentile25: return stats.percentile25
- case .median: return stats.median
- case .percentile75: return stats.percentile75
- case .percentile90: return stats.percentile90
- case .maximum: return stats.maximum
- }
- }
- }
- struct GlucoseDailyPercentileChart: View {
- let glucose: [GlucoseStored]
- let highLimit: Decimal
- let units: GlucoseUnits
- let timeInRangeType: TimeInRangeType
- let selectedInterval: Stat.StateModel.StatsTimeInterval
- @Binding var isDaySelected: Bool
- // Scrolling and selection states
- @State private var scrollPosition = Date()
- @State private var selectedDate: Date?
- @State private var updateTimer = Stat.UpdateTimer()
- @State private var visibleDailyStats: [GlucoseDailyPercentileStats] = []
- // State for selected percentile
- @State private var selectedPercentile: GlucosePercentileType?
- // State model for accessing the shared calculations
- let state: Stat.StateModel
- // Computes the visible date range based on the current scroll position
- @State private var visibleDateRange: (start: Date, end: Date) = (Date(), Date())
- private func calculateVisibleDailyStats() {
- let calendar = Calendar.current
- visibleDailyStats = state.dailyGlucosePercentileStats.filter { stat in
- let statDate = calendar.startOfDay(for: stat.date)
- return statDate >= calendar.startOfDay(for: visibleDateRange.start) &&
- statDate <= calendar.startOfDay(for: visibleDateRange.end)
- }
- }
- private func calculateVisibleDateRange() {
- visibleDateRange = StatChartUtils.visibleDateRange(from: scrollPosition, for: selectedInterval)
- }
- // Gets selected day stats
- private var selectedDateStats: GlucoseDailyPercentileStats? {
- selectedDate.flatMap { day in
- state.glucosePercentileCache[Calendar.current.startOfDay(for: day)]
- }
- }
- // Aggregates data from all visible days
- private var aggregatedVisibleStats: GlucoseDailyPercentileStats? {
- guard !visibleDailyStats.isEmpty else { return nil }
- // Collect all glucose values from visible days
- var allMinimums: [Double] = []
- var allMaximums: [Double] = []
- var all10thPercentiles: [Double] = []
- var all25thPercentiles: [Double] = []
- var allMedians: [Double] = []
- var all75thPercentiles: [Double] = []
- var all90thPercentiles: [Double] = []
- // Collect data from all visible days
- for stats in visibleDailyStats where stats.median > 0 {
- allMinimums.append(stats.minimum)
- allMaximums.append(stats.maximum)
- all10thPercentiles.append(stats.percentile10)
- all25thPercentiles.append(stats.percentile25)
- allMedians.append(stats.median)
- all75thPercentiles.append(stats.percentile75)
- all90thPercentiles.append(stats.percentile90)
- }
- // Calculate aggregated values
- let aggMinimum = allMinimums.min() ?? 0
- let aggMaximum = allMaximums.max() ?? 0
- let aggP10 = StatChartUtils.medianCalculationDouble(array: all10thPercentiles)
- let aggP25 = StatChartUtils.medianCalculationDouble(array: all25thPercentiles)
- let aggMedian = StatChartUtils.medianCalculationDouble(array: allMedians)
- let aggP75 = StatChartUtils.medianCalculationDouble(array: all75thPercentiles)
- let aggP90 = StatChartUtils.medianCalculationDouble(array: all90thPercentiles)
- // Create a new stats object with the visible date range and aggregated values
- return GlucoseDailyPercentileStats(
- date: visibleDateRange.start,
- readings: [], // Empty array since this is aggregated data
- minimum: aggMinimum,
- percentile10: aggP10,
- percentile25: aggP25,
- median: aggMedian,
- percentile75: aggP75,
- percentile90: aggP90,
- maximum: aggMaximum
- )
- }
- // Format a single date for display
- private func formatDate(_ date: Date) -> String {
- date.formatted(.dateTime.weekday(.wide).month(.wide).day().year())
- }
- // Get the appropriate detail view data
- private var detailViewData: (data: GlucoseDailyPercentileStats, dateText: String)? {
- if let selectedData = selectedDateStats {
- // Case 1: Selected specific day
- return (selectedData, selectedData.date.formatted(.dateTime.weekday(.wide).month(.wide).day().year()))
- } else if let aggregatedData = aggregatedVisibleStats {
- // Case 2: Using aggregated data
- return (aggregatedData, StatChartUtils.formatVisibleDateRange(
- from: visibleDateRange.start,
- to: visibleDateRange.end,
- for: selectedInterval
- ))
- }
- return nil
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- boxplotChart
- .frame(height: 300)
- // Display detail view if we have data
- if let viewData = detailViewData {
- GlucoseDailyPercentileDetailView(
- dayData: viewData.data,
- units: units,
- dateRangeText: viewData.dateText,
- selectedPercentile: $selectedPercentile
- )
- .padding(.top, 4)
- }
- }
- .onAppear {
- calculateVisibleDateRange()
- scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
- calculateVisibleDailyStats()
- }
- .onChange(of: scrollPosition) {
- updateTimer.scheduleUpdate {
- calculateVisibleDateRange()
- calculateVisibleDailyStats()
- }
- }
- .onChange(of: selectedInterval) { _, _ in
- selectedDate = nil
- selectedPercentile = nil
- isDaySelected = false
- scrollPosition = StatChartUtils.getInitialScrollPosition(for: selectedInterval)
- }
- }
- // Simple boxplot chart with improved visuals - broken down into components
- private var boxplotChart: some View {
- Chart {
- // First draw all the non-interactive elements
- ForEach(state.dailyGlucosePercentileStats) { day in
- if day.maximum > 0 { // Check if we have valid data
- // Add background components for each day
- spacerBarMark(for: day)
- percentileBarMark(
- for: day,
- startValue: day.minimum.asUnit(units),
- endValue: day.percentile10.asUnit(units),
- rangeName: "0-100%"
- )
- percentileBarMark(
- for: day,
- startValue: day.percentile10.asUnit(units),
- endValue: day.percentile25.asUnit(units),
- rangeName: "10-90%"
- )
- percentileBarMark(
- for: day,
- startValue: day.percentile25.asUnit(units),
- endValue: day.percentile75.asUnit(units),
- rangeName: "25-75%"
- )
- percentileBarMark(
- for: day,
- startValue: day.percentile75.asUnit(units),
- endValue: day.percentile90.asUnit(units),
- rangeName: "10-90%"
- )
- percentileBarMark(
- for: day,
- startValue: day.percentile90.asUnit(units),
- endValue: day.maximum.asUnit(units),
- rangeName: "0-100%"
- )
- }
- }
- // Draw median marks - these should appear above the percentile bars but below the selected percentile
- ForEach(state.dailyGlucosePercentileStats) { day in
- if day.maximum > 0 {
- medianMark(for: day)
- }
- }
- // Draw the selected percentile elements LAST so they're on top
- if let selectedPercentile = selectedPercentile {
- ForEach(state.dailyGlucosePercentileStats) { day in
- if day.maximum > 0 {
- // Line connecting points
- LineMark(
- x: .value("SelectedDate", day.date, unit: .day),
- y: .value("SelectedValue", selectedPercentile.getValue(from: day).asUnit(units))
- )
- .foregroundStyle(Color.purple)
- .lineStyle(StrokeStyle(lineWidth: selectedInterval == .total ? 1 : 2))
- .zIndex(200) // Set very high z-index
- // Point marks
- PointMark(
- x: .value("SelectedDate", day.date, unit: .day),
- y: .value("SelectedValue", selectedPercentile.getValue(from: day).asUnit(units))
- )
- .symbolSize(selectedInterval == .total ? 10 : 30)
- .foregroundStyle(Color.purple)
- .zIndex(300) // Even higher z-index for points
- }
- }
- }
- // Threshold lines
- RuleMark(
- y: .value("Low Limit", Double(timeInRangeType.bottomThreshold).asUnit(units))
- )
- .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
- .foregroundStyle(by: .value("Range", "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))"))
- .zIndex(100)
- RuleMark(
- y: .value("Mid Limit", Double(timeInRangeType.topThreshold).asUnit(units))
- )
- .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
- .foregroundStyle(by: .value("Range", "\(timeInRangeType.topThreshold.formatted(withUnits: units))"))
- .zIndex(100)
- RuleMark(
- y: .value("High Limit", Double(highLimit.asUnit(units)))
- )
- .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
- .foregroundStyle(by: .value("Range", "\(highLimit.formatted(withUnits: units))"))
- .zIndex(100)
- }
- .chartYAxis {
- AxisMarks(values: .automatic) { value in
- AxisGridLine()
- AxisTick()
- AxisValueLabel {
- if let glucoseValue = value.as(Double.self) {
- Text(
- units == .mmolL ?
- glucoseValue.formatted(.number.precision(.fractionLength(1))) :
- glucoseValue.formatted(.number.precision(.fractionLength(0)))
- )
- .font(.caption)
- }
- }
- }
- }
- .chartXAxis {
- AxisMarks(preset: .aligned, values: .stride(by: .day)) { value in
- if let date = value.as(Date.self) {
- let calendar = Calendar.current
- switch selectedInterval {
- case .month:
- // Mark the first day of the week
- let weekday = calendar.component(.weekday, from: date)
- if weekday == calendar.firstWeekday {
- AxisValueLabel(format: .dateTime.day(), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- case .total:
- // Mark the start of the month
- let day = calendar.component(.day, from: date)
- if day == 1 {
- AxisValueLabel(format: .dateTime.month(.abbreviated), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- default:
- // Mark every day
- AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true)
- .font(.footnote)
- AxisGridLine()
- }
- }
- }
- }
- .chartYScale(domain: glucoseYScaleDomain())
- .chartXSelection(value: $selectedDate.animation(.easeInOut))
- .onChange(of: selectedDate) { _, newValue in
- isDaySelected = newValue != nil
- // Clear percentile selection when a day is selected
- if newValue != nil {
- selectedPercentile = nil
- }
- }
- .chartForegroundStyleScale([
- "0-100%": .blue.opacity(0.15),
- "10-90%": .blue.opacity(0.3),
- "25-75%": .blue.opacity(0.5),
- "Median": .blue,
- "\(timeInRangeType.bottomThreshold.formatted(withUnits: units))": .red,
- "\(timeInRangeType.topThreshold.formatted(withUnits: units))": .mint,
- "\(highLimit.formatted(withUnits: units))": .orange
- ])
- .chartScrollableAxes(.horizontal)
- .chartScrollPosition(x: $scrollPosition)
- .chartScrollTargetBehavior(
- .valueAligned(
- matching: DateComponents(hour: 0),
- majorAlignment: .matching(
- StatChartUtils.alignmentComponents(for: selectedInterval)
- )
- )
- )
- .chartXVisibleDomain(length: StatChartUtils.visibleDomainLength(for: selectedInterval))
- }
- // MARK: - Chart Components
- private func percentileBarMark(
- for day: GlucoseDailyPercentileStats,
- startValue: Double,
- endValue: Double,
- rangeName: String
- ) -> some ChartContent {
- BarMark(
- x: .value("Day", day.date, unit: .day),
- y: .value("Percentage", endValue - startValue)
- )
- .foregroundStyle(by: .value("Range", rangeName))
- .opacity(getOpacity(for: day))
- }
- // Median mark - a horizontal line at the median point
- private func medianMark(for day: GlucoseDailyPercentileStats) -> some ChartContent {
- let baseDate = Calendar.current.startOfDay(for: day.date)
- let startOffset = Int(0.15 * 24 * 60) // 15% of minutes in a day
- let endOffset = Int(0.85 * 24 * 60) // 85% of minutes in a day
- return RuleMark(
- xStart: .value("DayStart", Calendar.current.date(byAdding: .minute, value: startOffset, to: baseDate)!),
- xEnd: .value("DayEnd", Calendar.current.date(byAdding: .minute, value: endOffset, to: baseDate)!),
- y: .value("Median", day.median.asUnit(units))
- )
- .lineStyle(StrokeStyle(lineWidth: 2))
- .foregroundStyle(by: .value("Range", "Median"))
- .opacity(getOpacity(for: day))
- }
- // Helper function to determine opacity based on selections
- private func getOpacity(for day: GlucoseDailyPercentileStats) -> Double {
- selectedDate.map { date in
- StatChartUtils.isSameTimeUnit(day.date, date, for: .total) ? 1 : 0.3
- } ?? 1
- }
- // Spacer box for each day
- private func spacerBarMark(for day: GlucoseDailyPercentileStats) -> some ChartContent {
- BarMark(
- x: .value("Day", day.date, unit: .day),
- y: .value("Percentage", day.minimum.asUnit(units))
- )
- .foregroundStyle(Color.clear)
- }
- // Calculate an appropriate Y axis domain for the chart
- private func glucoseYScaleDomain() -> ClosedRange<Double> {
- let padding = units == .mgdL ? 20.0 : 1.0
- let bottomLimit = 40.0.asUnit(units)
- let topLimit = 400.0.asUnit(units)
- if visibleDailyStats.isEmpty {
- return bottomLimit ... topLimit
- }
- var allValues: [Double] = []
- for day in visibleDailyStats where day.minimum > 0 {
- allValues.append(day.maximum.asUnit(units))
- }
- guard !allValues.isEmpty else {
- return bottomLimit ... topLimit
- }
- let maxValue = allValues.max() ?? topLimit
- return bottomLimit ... max(Double(highLimit.asUnit(units)), maxValue + padding)
- }
- }
|