| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314 |
- import Charts
- import SwiftUI
- struct GlucosePercentileChart: View {
- @Binding var selectedDuration: Stat.StateModel.StatsTimeInterval
- let state: Stat.StateModel
- let glucose: [GlucoseStored]
- let highLimit: Decimal
- let lowLimit: Decimal
- let units: GlucoseUnits
- let hourlyStats: [HourlyStats]
- @State private var scrollPosition = Date()
- @State private var selection: Date?
- @State private var isScrolling = false
- @State private var updateTimer = Stat.UpdateTimer()
- private func getDataRange() -> (start: Date, end: Date) {
- let calendar = Calendar.current
- switch selectedDuration {
- case .Day:
- return (
- calendar.startOfDay(for: scrollPosition),
- calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
- )
- case .Week:
- let weekStart = calendar
- .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
- return (weekStart, weekStart.addingTimeInterval(7 * 24 * 3600))
- case .Month:
- let monthStart = calendar.date(from: calendar.dateComponents([.year, .month], from: scrollPosition))!
- return (monthStart, calendar.date(byAdding: .month, value: 1, to: monthStart)!)
- case .Total:
- return (
- calendar.date(byAdding: .month, value: -3, to: scrollPosition)!,
- scrollPosition
- )
- }
- }
- private var visibleDateRange: (start: Date, end: Date) {
- let calendar = Calendar.current
- // Die X-Achse zeigt immer einen 24h-Tag
- return (
- calendar.startOfDay(for: scrollPosition),
- calendar.startOfDay(for: scrollPosition).addingTimeInterval(24 * 3600)
- )
- }
- private func formatVisibleDateRange() -> String {
- let calendar = Calendar.current
- let today = Date()
- switch selectedDuration {
- case .Day:
- let isToday = calendar.isDate(scrollPosition, inSameDayAs: today)
- let isYesterday = calendar.isDate(
- scrollPosition,
- inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!
- )
- return if isToday {
- "Today"
- } else if isYesterday {
- "Yesterday"
- } else {
- scrollPosition.formatted(date: .numeric, time: .omitted)
- }
- case .Week:
- let weekStart = calendar
- .date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: scrollPosition))!
- let weekEnd = calendar.date(byAdding: .day, value: 6, to: weekStart)!
- return "\(weekStart.formatted(date: .numeric, time: .omitted)) - \(weekEnd.formatted(date: .numeric, time: .omitted))"
- case .Month:
- let monthStart = calendar.date(
- from: calendar.dateComponents([.year, .month], from: scrollPosition)
- )!
- let monthEnd = calendar.date(byAdding: .month, value: 1, to: monthStart)!
- let lastDayOfMonth = calendar.date(byAdding: .day, value: -1, to: monthEnd)!
- return "\(monthStart.formatted(date: .numeric, time: .omitted)) - \(lastDayOfMonth.formatted(date: .numeric, time: .omitted))"
- case .Total:
- let endDate = scrollPosition
- let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
- return "\(startDate.formatted(date: .numeric, time: .omitted)) - \(endDate.formatted(date: .numeric, time: .omitted))"
- }
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- HStack(alignment: .top) {
- VStack(alignment: .leading) {
- Text("Ambulatory Glucose Profile")
- .font(.headline)
- Text("(AGP)")
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- Spacer()
- Text(formatVisibleDateRange())
- .font(.subheadline)
- .foregroundStyle(.secondary)
- }
- Chart {
- // TODO: ensure data is still correct
- // TODO: ensure area marks and line mark take color of respective range
- // Statistical view for longer periods
- // 10-90 percentile area
- ForEach(hourlyStats, id: \.hour) { stats in
- AreaMark(
- x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
- yStart: .value("10th Percentile", stats.percentile10),
- yEnd: .value("90th Percentile", stats.percentile90),
- series: .value("10-90", "10-90")
- )
- .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.2 : 0))
- }
- // 25-75 percentile area
- ForEach(hourlyStats, id: \.hour) { stats in
- AreaMark(
- x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
- yStart: .value("25th Percentile", stats.percentile25),
- yEnd: .value("75th Percentile", stats.percentile75),
- series: .value("25-75", "25-75")
- )
- .foregroundStyle(.blue.opacity(stats.median > 0 ? 0.3 : 0))
- }
- // Median line
- ForEach(hourlyStats.filter { $0.median > 0 }, id: \.hour) { stats in
- LineMark(
- x: .value("Hour", Calendar.current.dateForChartHour(stats.hour), unit: .hour),
- y: .value("Median", stats.median),
- series: .value("Median", "Median")
- )
- .lineStyle(StrokeStyle(lineWidth: 2))
- .foregroundStyle(.blue)
- }
- // Target range
- RuleMark(
- y: .value("High Limit", highLimit)
- )
- .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
- .foregroundStyle(.orange.gradient)
- // TODO: - Get target
- RuleMark(
- y: .value("Target", 100)
- )
- .lineStyle(StrokeStyle(lineWidth: 1.5))
- .foregroundStyle(.green.gradient)
- RuleMark(
- y: .value("Low Limit", lowLimit)
- )
- .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
- .foregroundStyle(.red.gradient)
- if let selection = selection,
- let stats = selectedStats
- {
- RuleMark(
- x: .value("Selected Time", selection)
- )
- .foregroundStyle(.secondary.opacity(0.3))
- .annotation(
- position: .top,
- spacing: 0,
- overflowResolution: .init(x: .fit, y: .disabled)
- ) {
- AGPSelectionPopover(
- stats: stats,
- time: selection,
- units: units
- )
- }
- }
- }
- .chartYAxis {
- AxisMarks(position: .trailing) { value in
- if let glucose = value.as(Double.self) {
- let glucoseValue = units == .mmolL ? Decimal(glucose).asMmolL : Decimal(glucose)
- AxisValueLabel {
- Text(glucoseValue.formatted(.number.precision(.fractionLength(units == .mmolL ? 1 : 0))))
- }
- AxisGridLine()
- }
- }
- }
- .chartXAxis {
- AxisMarks(values: .stride(by: .hour, count: 3)) { _ in
- AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .narrow)), centered: true, anchor: .top)
- AxisGridLine()
- }
- }
- .chartScrollableAxes(.horizontal)
- .chartScrollPosition(x: $scrollPosition)
- .chartXSelection(value: $selection)
- .chartXVisibleDomain(length: 24 * 3600)
- .chartScrollTargetBehavior(
- .valueAligned(
- matching: DateComponents(minute: 0),
- majorAlignment: .matching(DateComponents(hour: 0))
- )
- )
- .frame(height: 200)
- }
- // Update chart when scrolling
- .onChange(of: scrollPosition) {
- state.glucoseScrollPosition = scrollPosition
- state.updateDisplayedStats(for: .percentile)
- }
- // Reset scroll position when duration changes
- .onChange(of: selectedDuration) {
- scrollPosition = Date()
- state.glucoseScrollPosition = scrollPosition
- }
- }
- private var selectedStats: HourlyStats? {
- guard let selection = selection else { return nil }
- // Don't show stats for future times if viewing today
- if isToday && selection > Date() {
- return nil
- }
- let calendar = Calendar.current
- let hour = calendar.component(.hour, from: selection)
- return hourlyStats.first { Int($0.hour) == hour }
- }
- private var isToday: Bool {
- let calendar = Calendar.current
- let now = Date()
- return calendar.isDate(now, inSameDayAs: calendar.startOfDay(for: now))
- }
- }
- struct AGPSelectionPopover: View {
- let stats: HourlyStats
- let time: Date
- let units: GlucoseUnits
- var body: some View {
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- Image(systemName: "clock")
- Text(time.formatted(.dateTime.hour().minute(.twoDigits)))
- .font(.body).bold()
- }
- .font(.caption)
- .foregroundStyle(.secondary)
- Grid(alignment: .leading, horizontalSpacing: 8) {
- GridRow {
- Text("90%:")
- Text(stats.percentile90.formatted(.number))
- Text(units.rawValue)
- .foregroundStyle(.secondary)
- }
- GridRow {
- Text("75%:")
- Text(stats.percentile75.formatted(.number))
- Text(units.rawValue)
- .foregroundStyle(.secondary)
- }
- GridRow {
- Text("Median:")
- Text(stats.median.formatted(.number))
- Text(units.rawValue)
- .foregroundStyle(.secondary)
- }
- GridRow {
- Text("25%:")
- Text(stats.percentile25.formatted(.number))
- Text(units.rawValue)
- .foregroundStyle(.secondary)
- }
- GridRow {
- Text("10%:")
- Text(stats.percentile10.formatted(.number))
- Text(units.rawValue)
- .foregroundStyle(.secondary)
- }
- }
- .font(.caption)
- }
- .padding(8)
- .background {
- RoundedRectangle(cornerRadius: 8)
- .fill(.background)
- .shadow(radius: 2)
- }
- }
- }
- private extension Calendar {
- func startOfHour(for date: Date) -> Date {
- let components = dateComponents([.year, .month, .day, .hour], from: date)
- return self.date(from: components) ?? date
- }
- }
|