|
|
@@ -39,6 +39,32 @@ enum MainChartHelper {
|
|
|
}
|
|
|
|
|
|
enum Config {
|
|
|
+ /// How far back the chart's `startMarker` is anchored — the fixed 24 h
|
|
|
+ /// history window loaded on every open. Independent of the currently
|
|
|
+ /// visible viewport, which the user can pinch-zoom within this range.
|
|
|
+ static let chartHistorySeconds: TimeInterval = 24 * 3600
|
|
|
+ /// Visible x-axis window seeded on first launch of the chart (matches the old 6 h default).
|
|
|
+ static let defaultVisibleSeconds: TimeInterval = 6 * 3600
|
|
|
+ /// Tightest pinch-in zoom.
|
|
|
+ static let minVisibleSeconds: TimeInterval = 3 * 3600
|
|
|
+ /// Widest pinch-out zoom.
|
|
|
+ static let maxVisibleSeconds: TimeInterval = 24 * 3600
|
|
|
+ /// Double-tap cycles the visible window through these presets.
|
|
|
+ static let zoomPresets: [TimeInterval] = [6 * 3600, 12 * 3600, 24 * 3600]
|
|
|
+ /// Geometric grid for pinch commits (~4 % per step). Every committed zoom step
|
|
|
+ /// re-lays the full-width canvas, so this bounds a halving of the visible window
|
|
|
+ /// to roughly 18 re-layouts instead of hundreds.
|
|
|
+ static let zoomStepRatio: Double = 1.04
|
|
|
+ /// How far (pt) a one-finger touch may travel and still count as a stationary
|
|
|
+ /// press-to-inspect; beyond this the touch becomes a pan.
|
|
|
+ static let inspectMovementTolerance: CGFloat = 10
|
|
|
+ /// Width (pt) of the strips at the viewport edges where a scrubbing finger makes
|
|
|
+ /// the chart auto-pan to reveal more data; pan speed scales with edge depth.
|
|
|
+ static let edgePanZoneWidth: CGFloat = 44
|
|
|
+ /// How long (s) a one-finger touch must rest before the inspect popover appears.
|
|
|
+ /// Without this, every drag briefly triggered inspect on touch-down — and each
|
|
|
+ /// selection change re-lays the canvas, stalling the pan as it starts.
|
|
|
+ static let inspectHoldDelay: TimeInterval = 0.15
|
|
|
static let bolusSize: CGFloat = 5
|
|
|
static let bolusScale: CGFloat = 1.8
|
|
|
static let carbsSize: CGFloat = 5
|
|
|
@@ -49,6 +75,30 @@ enum MainChartHelper {
|
|
|
static let minGlucose = 45
|
|
|
}
|
|
|
|
|
|
+ /// Visual scaling applied to IOB values on the shared COB/IOB axis (COB is usually
|
|
|
+ /// much larger than IOB). Single source of truth for the chart marks, the y-domain,
|
|
|
+ /// and the shell's selection overlay.
|
|
|
+ static func scaledIobAmount<T: Numeric & Comparable>(_ rawAmount: T) -> T
|
|
|
+ where T: ExpressibleByIntegerLiteral
|
|
|
+ {
|
|
|
+ rawAmount > 0 ? rawAmount * 8 : rawAmount * 9
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The combined y-domain of the COB/IOB chart. Used by both the canvas chart and the
|
|
|
+ /// shell's selection overlay, which must agree exactly on the value-to-pixel mapping.
|
|
|
+ static func cobIobYDomain(
|
|
|
+ minCob: Decimal,
|
|
|
+ maxCob: Decimal,
|
|
|
+ minIob: Decimal,
|
|
|
+ maxIob: Decimal
|
|
|
+ ) -> ClosedRange<Double> {
|
|
|
+ let iobMin = scaledIobAmount(minIob)
|
|
|
+ let iobMax = scaledIobAmount(maxIob)
|
|
|
+ let minValue = min(minCob, iobMin)
|
|
|
+ let maxValue = max(maxCob, iobMax)
|
|
|
+ return Double(minValue) ... Double(maxValue)
|
|
|
+ }
|
|
|
+
|
|
|
static func bolusOffset(units: GlucoseUnits) -> Decimal {
|
|
|
units == .mgdL ? 20 : (20 / 18)
|
|
|
}
|
|
|
@@ -94,7 +144,7 @@ enum MainChartHelper {
|
|
|
|
|
|
// MARK: - Rule Marks and Charts configurations
|
|
|
|
|
|
-extension MainChartView {
|
|
|
+extension MainChartCanvas {
|
|
|
func drawCurrentTimeMarker() -> some ChartContent {
|
|
|
RuleMark(
|
|
|
x: .value(
|
|
|
@@ -105,34 +155,72 @@ extension MainChartView {
|
|
|
).lineStyle(.init(lineWidth: 2, dash: [3])).foregroundStyle(Color(.systemGray2))
|
|
|
}
|
|
|
|
|
|
- func drawStartRuleMark() -> some ChartContent {
|
|
|
- RuleMark(
|
|
|
- x: .value(
|
|
|
- "",
|
|
|
- state.startMarker,
|
|
|
- unit: .second
|
|
|
+ /// High and low threshold lines. Horizontal rules span the whole x-domain, so they stay
|
|
|
+ /// visually static while the chart scrolls. (Moved here from the deleted static-axis
|
|
|
+ /// overlay chart.)
|
|
|
+ @ChartContentBuilder func drawThresholdLines() -> some ChartContent {
|
|
|
+ if thresholdLines {
|
|
|
+ // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
|
|
|
+ let hardCodedLow = Decimal(55)
|
|
|
+ let hardCodedHigh = Decimal(220)
|
|
|
+ let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
|
|
|
+
|
|
|
+ let highColor = Trio.getDynamicGlucoseColor(
|
|
|
+ glucoseValue: highGlucose,
|
|
|
+ highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
|
|
|
+ lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
|
|
|
+ targetGlucose: currentGlucoseTarget,
|
|
|
+ glucoseColorScheme: glucoseColorScheme
|
|
|
)
|
|
|
- ).foregroundStyle(Color.clear)
|
|
|
+ let lowColor = Trio.getDynamicGlucoseColor(
|
|
|
+ glucoseValue: lowGlucose,
|
|
|
+ highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
|
|
|
+ lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
|
|
|
+ targetGlucose: currentGlucoseTarget,
|
|
|
+ glucoseColorScheme: glucoseColorScheme
|
|
|
+ )
|
|
|
+
|
|
|
+ RuleMark(y: .value("High", units == .mgdL ? highGlucose : highGlucose.asMmolL))
|
|
|
+ .foregroundStyle(highColor)
|
|
|
+ .lineStyle(.init(lineWidth: 1, dash: [5]))
|
|
|
+ RuleMark(y: .value("Low", units == .mgdL ? lowGlucose : lowGlucose.asMmolL))
|
|
|
+ .foregroundStyle(lowColor)
|
|
|
+ .lineStyle(.init(lineWidth: 1, dash: [5]))
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- func drawEndRuleMark() -> some ChartContent {
|
|
|
- RuleMark(
|
|
|
- x: .value(
|
|
|
- "",
|
|
|
- state.endMarker,
|
|
|
- unit: .second
|
|
|
- )
|
|
|
- ).foregroundStyle(Color.clear)
|
|
|
+ /// X-axis grid/label stride for the current continuous zoom level. Same ladder as the
|
|
|
+ /// old presets: up to 6 h visible -> 1 h, up to 12 h -> 2 h, wider -> 4 h.
|
|
|
+ var xAxisStrideHours: Int {
|
|
|
+ let visibleHours = visibleSeconds / 3600
|
|
|
+ if visibleHours <= 6 { return 1 }
|
|
|
+ if visibleHours <= 12 { return 2 }
|
|
|
+ return 4
|
|
|
}
|
|
|
|
|
|
- func basalChartPlotStyle(_ plotContent: ChartPlotContent) -> some View {
|
|
|
- plotContent
|
|
|
- .rotationEffect(.degrees(180))
|
|
|
- .scaleEffect(x: -1, y: 1)
|
|
|
+ /// Calendar-hour axis mark dates for the given range, anchored to absolute time
|
|
|
+ /// (multiples of `xAxisStrideHours` counted from midnight, DST-safe via `Calendar`),
|
|
|
+ /// unlike `.stride(by: .hour, count:)`, which anchors its sequence to the domain start.
|
|
|
+ func hourAxisMarks(over range: ClosedRange<Date>) -> [Date] {
|
|
|
+ let strideHours = xAxisStrideHours
|
|
|
+ var components = calendar.dateComponents([.year, .month, .day, .hour], from: range.lowerBound)
|
|
|
+ let hour = components.hour ?? 0
|
|
|
+ components.hour = hour - hour % strideHours
|
|
|
+ guard var mark = calendar.date(from: components) else { return [] }
|
|
|
+
|
|
|
+ var marks: [Date] = []
|
|
|
+ while mark <= range.upperBound {
|
|
|
+ if mark >= range.lowerBound {
|
|
|
+ marks.append(mark)
|
|
|
+ }
|
|
|
+ guard let next = calendar.date(byAdding: .hour, value: strideHours, to: mark) else { break }
|
|
|
+ mark = next
|
|
|
+ }
|
|
|
+ return marks
|
|
|
}
|
|
|
|
|
|
var mainChartXAxis: some AxisContent {
|
|
|
- AxisMarks(values: .stride(by: .hour, count: screenHours > 6 ? (screenHours > 12 ? 4 : 2) : 1)) { _ in
|
|
|
+ AxisMarks(values: hourAxisMarks(over: state.startMarker ... state.endMarker)) { _ in
|
|
|
if displayXgridLines {
|
|
|
AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
|
|
|
} else {
|
|
|
@@ -141,8 +229,11 @@ extension MainChartView {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /// Grid lines PLUS hour labels. Used only by the bottom (COB/IOB) pane so the time
|
|
|
+ /// labels render exactly once for the whole stack; the basal and glucose panes use
|
|
|
+ /// `mainChartXAxis` (grid lines only) at the same absolute-anchored mark dates.
|
|
|
var basalChartXAxis: some AxisContent {
|
|
|
- AxisMarks(values: .stride(by: .hour, count: screenHours > 6 ? (screenHours > 12 ? 4 : 2) : 1)) { _ in
|
|
|
+ AxisMarks(values: hourAxisMarks(over: state.startMarker ... state.endMarker)) { _ in
|
|
|
if displayXgridLines {
|
|
|
AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
|
|
|
} else {
|
|
|
@@ -153,27 +244,12 @@ extension MainChartView {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- var mainChartYAxis: some AxisContent {
|
|
|
- AxisMarks(position: .trailing) { value in
|
|
|
-
|
|
|
- if displayYgridLines {
|
|
|
- AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
|
|
|
- } else {
|
|
|
- AxisGridLine(stroke: .init(lineWidth: 0, dash: [2, 3]))
|
|
|
- }
|
|
|
-
|
|
|
- if let glucoseValue = value.as(Double.self), glucoseValue > 0 {
|
|
|
- /// fix offset between the two charts...
|
|
|
- if units == .mmolL {
|
|
|
- AxisTick(length: 7, stroke: .init(lineWidth: 7)).foregroundStyle(Color.clear)
|
|
|
- }
|
|
|
- AxisValueLabel().font(.footnote).foregroundStyle(Color.primary)
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
var cobIobChartYAxis: some AxisContent {
|
|
|
- AxisMarks(position: .trailing) { _ in
|
|
|
+ // Only two y-grid lines — at the top and bottom of the pane — instead of
|
|
|
+ // automatic marks: the values are exactly the bounds of the same combined
|
|
|
+ // COB/IOB domain the chart is scaled to.
|
|
|
+ let domain = combinedYDomain()
|
|
|
+ return AxisMarks(position: .trailing, values: [domain.lowerBound, domain.upperBound]) { _ in
|
|
|
if displayYgridLines {
|
|
|
AxisGridLine(stroke: .init(lineWidth: 0.5, dash: [2, 3]))
|
|
|
} else {
|
|
|
@@ -181,8 +257,4 @@ extension MainChartView {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- func fullWidth(viewWidth: CGFloat) -> CGFloat {
|
|
|
- viewWidth * CGFloat(hours) / CGFloat(min(max(screenHours, 2), 24))
|
|
|
- }
|
|
|
}
|