Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)))"
)
}
Expand Down
81 changes: 73 additions & 8 deletions LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -211,6 +219,9 @@ private struct MainBGChart: View {
selectionOverlay(viewportWidth: viewportWidth)
.allowsHitTesting(false)

overrideBandLabelsOverlay(viewportWidth: viewportWidth)
.allowsHitTesting(false)

if !interaction.followLatest {
jumpToNowButton
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
68 changes: 59 additions & 9 deletions LoopFollow/Stats/AGP/AGPGraphView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,72 @@ 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<Double> {
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,
range: Percentile.allCases.map(\.color)
)
.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))
Expand All @@ -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)))
Expand Down
4 changes: 3 additions & 1 deletion LoopFollow/Stats/AGP/AGPView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Stats/GRI/GRIRiskGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion LoopFollow/Stats/GRI/GRIView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion LoopFollow/Stats/TIR/TIRGraphView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]))
Expand Down
4 changes: 3 additions & 1 deletion LoopFollow/Stats/TIR/TIRView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
Loading