diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index fda817498..62cba698e 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -83,6 +83,7 @@ final class BGChartModel: ObservableObject { let end: Date let yBottom: Double let yTop: Double + let label: String let pillText: String var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" } } @@ -521,12 +522,15 @@ final class BGChartModel: ObservableObject { let yTop = maxBG - 5 let yBottom = maxBG - 25 overrides = vc.overrideGraphData.map { - BandRect( + let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines) + let displayName = overrideName.isEmpty ? "Override" : overrideName + return BandRect( start: Date(timeIntervalSince1970: $0.date), end: Date(timeIntervalSince1970: $0.endDate), yBottom: yBottom, yTop: yTop, - pillText: "Override x\(String(format: "%.2f", $0.insulNeedsScaleFactor))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + label: displayName, + pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } tempTargets = vc.tempTargetGraphData.map { @@ -536,6 +540,7 @@ final class BGChartModel: ObservableObject { end: Date(timeIntervalSince1970: $0.endDate), yBottom: yBottom, yTop: yTop, + label: "Temp Target", pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 0f0597373..ec6ab71b4 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -57,6 +57,14 @@ private enum BGChartConfig { static let tapHitRadius: CGFloat = 30 } +/// Small y-domain headroom keeps the top axis label readable instead of +/// pinning it to the chart edge. +private func chartYDomainUpperBound(_ maxBG: Double) -> Double { + let clampedMax = max(maxBG, 1) + let topPadding = max(clampedMax * 0.02, 1) + return clampedMax + topPadding +} + struct BGChartView: View { enum Config { case small @@ -211,6 +219,9 @@ private struct MainBGChart: View { selectionOverlay(viewportWidth: viewportWidth) .allowsHitTesting(false) + overrideBandLabelsOverlay(viewportWidth: viewportWidth) + .allowsHitTesting(false) + if !interaction.followLatest { jumpToNowButton } @@ -646,6 +657,23 @@ private struct MainBGChart: View { "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))" } + private func bandPillTexts(at date: Date) -> [String] { + var texts: [String] = [] + if let band = model.overrides + .filter({ date >= $0.start && date <= $0.end }) + .max(by: { $0.start < $1.start }) + { + texts.append(band.pillText) + } + if let band = model.tempTargets + .filter({ date >= $0.start && date <= $0.end }) + .max(by: { $0.start < $1.start }) + { + texts.append(band.pillText) + } + return texts + } + /// Band (override / temp target) under the given date+value, if any. private func bandAnchor(at date: Date, value: Double) -> SelectionAnchor? { for band in model.overrides where date >= band.start && date <= band.end { @@ -712,14 +740,16 @@ private struct MainBGChart: View { items.append(nearestBG) } if let primary = items.min(by: { $0.distance < $1.distance }) { - return SelectionAnchor(date: primary.date, value: primary.value, texts: items.map(\.text)) + let texts = items.map(\.text) + bandPillTexts(at: selected) + return SelectionAnchor(date: primary.date, value: primary.value, texts: texts) } // Nothing under the finger. Reach for the nearest treatment (data gaps // leave treatments without BG neighbors), then for a band (any height) // at the scrub time. if let nearestTreatment, nearestTreatment.distance <= BGChartConfig.selectionTolerance { - return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: [nearestTreatment.text]) + let texts = [nearestTreatment.text] + bandPillTexts(at: selected) + return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: texts) } for band in model.overrides where selected >= band.start && selected <= band.end { let midY = (band.yTop + band.yBottom) / 2 @@ -763,7 +793,11 @@ private struct MainBGChart: View { ) return bandAnchor(at: date, value: value(atY: location.y)) } - return best + if let best { + let texts = best.texts + bandPillTexts(at: best.date) + return SelectionAnchor(date: best.date, value: best.value, texts: texts) + } + return nil } private func handleTap(at location: CGPoint, viewportWidth: CGFloat) { @@ -788,14 +822,45 @@ private struct MainBGChart: View { } private func yPosition(forValue value: Double) -> CGFloat { - let clamped = min(max(value, 0), model.maxBG) - return plotFrame.minY + CGFloat(1 - clamped / model.maxBG) * plotFrame.height + let yMax = chartYDomainUpperBound(model.maxBG) + let clamped = min(max(value, 0), yMax) + return plotFrame.minY + CGFloat(1 - clamped / yMax) * plotFrame.height } private func value(atY y: CGFloat) -> Double { guard plotFrame.height > 0 else { return 0 } + let yMax = chartYDomainUpperBound(model.maxBG) let fraction = 1 - (y - plotFrame.minY) / plotFrame.height - return Double(min(max(fraction, 0), 1)) * model.maxBG + return Double(min(max(fraction, 0), 1)) * yMax + } + + @ViewBuilder + private func overrideBandLabelsOverlay(viewportWidth: CGFloat) -> some View { + if plotFrame.height > 0 { + let visibleStart = interaction.scrollPosition + let visibleEnd = interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds) + + ForEach(model.overrides.filter { $0.end >= visibleStart && $0.start <= visibleEnd }) { band in + let visibleBandStart = max(band.start, visibleStart) + let visibleBandEnd = min(band.end, visibleEnd) + let xStart = xPosition(for: visibleBandStart, viewportWidth: viewportWidth) + let xEnd = xPosition(for: visibleBandEnd, viewportWidth: viewportWidth) + let bandWidth = max(0, xEnd - xStart) + let y = yPosition(forValue: (band.yBottom + band.yTop) / 2) + + if bandWidth > 24 { + Text(band.label) + .font(.caption2) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + .padding(.trailing, 10) + .frame(width: max(0, bandWidth - 6), alignment: .trailing) + .clipped() + .position(x: xStart + bandWidth / 2, y: y) + } + } + } } /// Vertical indicator + pill for the current selection, rendered in the @@ -964,7 +1029,7 @@ private struct BGChartCanvas: View, Equatable { } } .chartXScale(domain: windowStart ... windowEnd) - .chartYScale(domain: 0 ... model.maxBG) + .chartYScale(domain: 0 ... chartYDomainUpperBound(model.maxBG)) .chartLegend(.hidden) .chartYAxis(.hidden) @@ -1401,7 +1466,7 @@ private struct StaticYAxisOverlay: View, Equatable { .opacity(0) } .chartXScale(domain: 0 ... 1) - .chartYScale(domain: 0 ... maxBG) + .chartYScale(domain: 0 ... chartYDomainUpperBound(maxBG)) .chartLegend(.hidden) .chartXAxis { AxisMarks(values: [0.5]) { _ in diff --git a/LoopFollow/Stats/AGP/AGPGraphView.swift b/LoopFollow/Stats/AGP/AGPGraphView.swift index 0c9726577..11e8dbb04 100644 --- a/LoopFollow/Stats/AGP/AGPGraphView.swift +++ b/LoopFollow/Stats/AGP/AGPGraphView.swift @@ -48,15 +48,64 @@ struct AGPGraphView: View { } } + private var activeThresholds: (low: Double, high: Double) { + UnitSettingsStore.shared.effectiveThresholds() + } + + private var lowThresholdLabel: String { + Localizer.toDisplayUnits(String(activeThresholds.low)) + } + + private var highThresholdLabel: String { + Localizer.toDisplayUnits(String(activeThresholds.high)) + } + + private var plottedMinValue: Double { + min(points.map(\.value).min() ?? activeThresholds.low, activeThresholds.low) + } + + private var plottedMaxValue: Double { + max(points.map(\.value).max() ?? activeThresholds.high, activeThresholds.high) + } + + private var yDomain: ClosedRange { + let span = max(plottedMaxValue - plottedMinValue, 1) + let topPadding = max(span * 0.05, 1) + return plottedMinValue ... (plottedMaxValue + topPadding) + } + + private var yAxisEndpoints: [Double] { + if abs(plottedMaxValue - plottedMinValue) < 0.0001 { + return [plottedMinValue] + } + return [plottedMinValue, plottedMaxValue] + } + var body: some View { - Chart(points) { point in - LineMark( - x: .value("hour", point.hour), - y: .value("bg", point.value) - ) - .foregroundStyle(by: .value("percentile", point.percentile)) - .lineStyle(StrokeStyle(lineWidth: point.percentile.lineWidth)) - .interpolationMethod(.linear) + Chart { + RuleMark(y: .value("lowBoundary", activeThresholds.low)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .annotation(position: .leading, alignment: .leading) { + Text(lowThresholdLabel) + .font(.caption2) + } + + RuleMark(y: .value("highBoundary", activeThresholds.high)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .annotation(position: .leading, alignment: .leading) { + Text(highThresholdLabel) + .font(.caption2) + } + + ForEach(points) { point in + LineMark( + x: .value("hour", point.hour), + y: .value("bg", point.value) + ) + .foregroundStyle(by: .value("percentile", point.percentile)) + .lineStyle(StrokeStyle(lineWidth: point.percentile.lineWidth)) + .interpolationMethod(.linear) + } } .chartForegroundStyleScale( domain: Percentile.allCases, @@ -64,6 +113,7 @@ struct AGPGraphView: View { ) .chartLegend(.hidden) .chartXScale(domain: 0 ... 24) + .chartYScale(domain: yDomain) .chartXAxis { AxisMarks(position: .bottom, values: Array(stride(from: 0, through: 24, by: 3))) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) @@ -77,7 +127,7 @@ struct AGPGraphView: View { } } .chartYAxis { - AxisMarks(position: .trailing) { value in + AxisMarks(position: .leading, values: yAxisEndpoints) { value in AxisValueLabel { if let raw = value.as(Double.self) { Text(Localizer.toDisplayUnits(String(raw))) diff --git a/LoopFollow/Stats/AGP/AGPView.swift b/LoopFollow/Stats/AGP/AGPView.swift index 6179b7b39..9d0cfa6d2 100644 --- a/LoopFollow/Stats/AGP/AGPView.swift +++ b/LoopFollow/Stats/AGP/AGPView.swift @@ -15,8 +15,10 @@ struct AGPView: View { AGPGraphView(agpData: viewModel.agpData) .frame(height: 200) + .padding(.top, 10) + .padding(.horizontal, 8) + .padding(.bottom, 4) .allowsHitTesting(false) - .clipped() // Legend HStack(spacing: 16) { diff --git a/LoopFollow/Stats/GRI/GRIRiskGridView.swift b/LoopFollow/Stats/GRI/GRIRiskGridView.swift index 808d81b96..814942cb9 100644 --- a/LoopFollow/Stats/GRI/GRIRiskGridView.swift +++ b/LoopFollow/Stats/GRI/GRIRiskGridView.swift @@ -84,7 +84,7 @@ struct GRIRiskGridView: View { ) .chartLegend(.hidden) .chartXScale(domain: 0 ... 30) - .chartYScale(domain: 0 ... 60) + .chartYScale(domain: 0 ... 61) .chartXAxis { AxisMarks(position: .bottom) { value in AxisTick() diff --git a/LoopFollow/Stats/GRI/GRIView.swift b/LoopFollow/Stats/GRI/GRIView.swift index cd6889008..4480b5df5 100644 --- a/LoopFollow/Stats/GRI/GRIView.swift +++ b/LoopFollow/Stats/GRI/GRIView.swift @@ -43,9 +43,11 @@ struct GRIView: View { gri: viewModel.gri ?? 0 ) .frame(height: 250) + .padding(.top, 10) .padding(.leading, yAxisLabelInset) + .padding(.trailing, 8) + .padding(.bottom, 4) .allowsHitTesting(false) - .clipped() Text("Hyperglycemia Component (%)") .font(.caption2) diff --git a/LoopFollow/Stats/TIR/TIRGraphView.swift b/LoopFollow/Stats/TIR/TIRGraphView.swift index 6c5f978b8..03a84a14b 100644 --- a/LoopFollow/Stats/TIR/TIRGraphView.swift +++ b/LoopFollow/Stats/TIR/TIRGraphView.swift @@ -55,7 +55,7 @@ struct TIRGraphView: View { } .chartForegroundStyleScale(domain: Band.allCases, range: Band.allCases.map(\.color)) .chartLegend(.hidden) - .chartYScale(domain: 0 ... 100) + .chartYScale(domain: 0 ... 102) .chartYAxis { AxisMarks(position: .leading, values: [0, 25, 50, 75, 100]) { value in AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5, dash: [5, 5])) diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index fade4cc2e..8ff66e273 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -27,8 +27,10 @@ struct TIRView: View { if !viewModel.tirData.isEmpty { TIRGraphView(tirData: viewModel.tirData) .frame(height: 250) + .padding(.top, 10) + .padding(.horizontal, 8) + .padding(.bottom, 4) .allowsHitTesting(false) - .clipped() VStack(alignment: .leading, spacing: 8) { if let average = viewModel.tirData.first(where: { $0.period == .average }) {