From 45b808a4bf0693818848512c8947b75360a93543 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:32:26 -0400 Subject: [PATCH 1/4] Select one chart event while inspecting --- LoopFollow/Charts/BGChartView.swift | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9098bd123..9818963d9 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -148,8 +148,8 @@ private struct MainBGChart: View { /// Leading edge captured when a one-finger drag transitions into panning. @State private var panBaseline: Date? - /// Date under the user's finger while inspecting, else nil. - @State private var selection: Date? + /// Exact plotted event under the user's finger while inspecting, else nil. + @State private var selection: SelectionAnchor? /// Anchor selected by tapping a mark; sticky until the user taps empty /// space, taps another mark, or starts a pan/zoom/inspect. @@ -439,7 +439,7 @@ private struct MainBGChart: View { } if isInspectLatched { - updateSelection(atViewportX: value.location.x, viewportWidth: viewportWidth) + updateSelection(at: value.location, viewportWidth: viewportWidth) return } @@ -521,20 +521,15 @@ private struct MainBGChart: View { UIImpactFeedbackGenerator(style: .medium).impactOccurred() scrubHaptic.prepare() if let location = lastTouchLocation { - updateSelection(atViewportX: location.x, viewportWidth: viewportWidth) + updateSelection(at: location, viewportWidth: viewportWidth) } } } - private func updateSelection(atViewportX x: CGFloat, viewportWidth: CGFloat) { - let fraction = min(max(x / viewportWidth, 0), 1) - let date = interaction.scrollPosition.addingTimeInterval( - interaction.visibleSeconds * TimeInterval(fraction) - ) - selection = date - // A featherlight tick whenever the indicator snaps to a different item. - let captureWindow = scrubCaptureWindow(viewportWidth: viewportWidth) - if let anchor = selectionAnchor(for: date, captureWindow: captureWindow), anchor.date != lastHapticAnchorDate { + private func updateSelection(at location: CGPoint, viewportWidth: CGFloat) { + selection = tappedAnchor(at: location, viewportWidth: viewportWidth) + // A featherlight tick whenever the finger moves onto a different mark. + if let anchor = selection, anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() @@ -854,9 +849,9 @@ private struct MainBGChart: View { } /// The anchor the overlay should show: a live scrub wins over a sticky tap. - private func activeAnchor(viewportWidth: CGFloat) -> SelectionAnchor? { - if isInspectLatched, let selected = selection { - return selectionAnchor(for: selected, captureWindow: scrubCaptureWindow(viewportWidth: viewportWidth)) + private func activeAnchor(viewportWidth _: CGFloat) -> SelectionAnchor? { + if isInspectLatched { + return selection } return tapped } From 6913702246d201b0b1c06e3eb40d74fc38570897 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:26:02 -0400 Subject: [PATCH 2/4] Keep chart scrub attached during vertical movement --- .../Charts/BGChartScrubAttachment.swift | 19 ++++++ LoopFollow/Charts/BGChartView.swift | 9 ++- .../Charts/BGChartScrubAttachmentTests.swift | 65 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 LoopFollow/Charts/BGChartScrubAttachment.swift create mode 100644 Tests/Charts/BGChartScrubAttachmentTests.swift diff --git a/LoopFollow/Charts/BGChartScrubAttachment.swift b/LoopFollow/Charts/BGChartScrubAttachment.swift new file mode 100644 index 000000000..14a09b800 --- /dev/null +++ b/LoopFollow/Charts/BGChartScrubAttachment.swift @@ -0,0 +1,19 @@ +// LoopFollow +// BGChartScrubAttachment.swift + +import CoreGraphics + +func bgChartScrubProbeLocation( + fingerLocation: CGPoint, + trackedPlotY: CGFloat? +) -> CGPoint { + guard let trackedPlotY, trackedPlotY.isFinite else { return fingerLocation } + return CGPoint(x: fingerLocation.x, y: trackedPlotY) +} + +func retainedBGChartScrubSelection( + current: Value?, + proposed: Value? +) -> Value? { + proposed ?? current +} diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9818963d9..c18fa14b9 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -527,7 +527,14 @@ private struct MainBGChart: View { } private func updateSelection(at location: CGPoint, viewportWidth: CGFloat) { - selection = tappedAnchor(at: location, viewportWidth: viewportWidth) + let trackedPlotY = selection.map { yPosition(forValue: $0.value) } + let probeLocation = bgChartScrubProbeLocation( + fingerLocation: location, + trackedPlotY: trackedPlotY + ) + let proposed = tappedAnchor(at: probeLocation, viewportWidth: viewportWidth) + selection = retainedBGChartScrubSelection(current: selection, proposed: proposed) + // A featherlight tick whenever the finger moves onto a different mark. if let anchor = selection, anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date diff --git a/Tests/Charts/BGChartScrubAttachmentTests.swift b/Tests/Charts/BGChartScrubAttachmentTests.swift new file mode 100644 index 000000000..d82f46ea8 --- /dev/null +++ b/Tests/Charts/BGChartScrubAttachmentTests.swift @@ -0,0 +1,65 @@ +// LoopFollow +// BGChartScrubAttachmentTests.swift + +import CoreGraphics +@testable import LoopFollow +import Testing + +struct BGChartScrubAttachmentTests { + @Test("initial scrub uses the finger's full position") + func unattachedProbeUsesFingerPosition() { + let finger = CGPoint(x: 120, y: 280) + + #expect(bgChartScrubProbeLocation(fingerLocation: finger, trackedPlotY: nil) == finger) + } + + @Test("attached scrub follows finger horizontally while ignoring vertical movement") + func attachedProbeUsesTrackedPlotHeight() { + let movedFinger = CGPoint(x: 175, y: 20) + + #expect( + bgChartScrubProbeLocation( + fingerLocation: movedFinger, + trackedPlotY: 240 + ) == CGPoint(x: 175, y: 240) + ) + } + + @Test("tracked height advances with the newly selected point") + func probeFollowsUpdatedPlotHeight() { + let finger = CGPoint(x: 210, y: 350) + + #expect( + bgChartScrubProbeLocation( + fingerLocation: finger, + trackedPlotY: 195 + ) == CGPoint(x: 210, y: 195) + ) + #expect( + bgChartScrubProbeLocation( + fingerLocation: finger, + trackedPlotY: 170 + ) == CGPoint(x: 210, y: 170) + ) + } + + @Test("temporary misses retain the attached selection") + func missRetainsCurrentSelection() { + #expect( + retainedBGChartScrubSelection( + current: "current SMB", + proposed: nil + ) == "current SMB" + ) + } + + @Test("a newly resolved point advances the selection") + func proposalReplacesCurrentSelection() { + #expect( + retainedBGChartScrubSelection( + current: "current BG", + proposed: "next BG" + ) == "next BG" + ) + } +} From 2e69ccd06e3a07da2c95bc83e9f6f12f979c3368 Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:56:34 -0400 Subject: [PATCH 3/4] Advance chart scrub by horizontal position --- .../Charts/BGChartScrubAttachment.swift | 31 +++-- LoopFollow/Charts/BGChartView.swift | 38 ++++- .../Charts/BGChartScrubAttachmentTests.swift | 131 ++++++++++++------ 3 files changed, 143 insertions(+), 57 deletions(-) diff --git a/LoopFollow/Charts/BGChartScrubAttachment.swift b/LoopFollow/Charts/BGChartScrubAttachment.swift index 14a09b800..190ec0468 100644 --- a/LoopFollow/Charts/BGChartScrubAttachment.swift +++ b/LoopFollow/Charts/BGChartScrubAttachment.swift @@ -3,17 +3,26 @@ import CoreGraphics -func bgChartScrubProbeLocation( - fingerLocation: CGPoint, - trackedPlotY: CGFloat? -) -> CGPoint { - guard let trackedPlotY, trackedPlotY.isFinite else { return fingerLocation } - return CGPoint(x: fingerLocation.x, y: trackedPlotY) +struct BGChartHorizontalScrubCandidate { + let value: Value + let plotX: CGFloat } -func retainedBGChartScrubSelection( - current: Value?, - proposed: Value? -) -> Value? { - proposed ?? current +func horizontallyAdvancedBGChartScrubSelection( + current: BGChartHorizontalScrubCandidate, + proposals: [BGChartHorizontalScrubCandidate], + cursorX: CGFloat, + captureRadius: CGFloat +) -> Value { + var best = current + var bestDistance = abs(current.plotX - cursorX) + + for proposal in proposals { + let distance = abs(proposal.plotX - cursorX) + guard distance <= captureRadius, distance < bestDistance else { continue } + best = proposal + bestDistance = distance + } + + return best.value } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index c18fa14b9..48308d1b6 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -527,13 +527,37 @@ private struct MainBGChart: View { } private func updateSelection(at location: CGPoint, viewportWidth: CGFloat) { - let trackedPlotY = selection.map { yPosition(forValue: $0.value) } - let probeLocation = bgChartScrubProbeLocation( - fingerLocation: location, - trackedPlotY: trackedPlotY - ) - let proposed = tappedAnchor(at: probeLocation, viewportWidth: viewportWidth) - selection = retainedBGChartScrubSelection(current: selection, proposed: proposed) + if let current = selection { + let laneProbe = CGPoint(x: location.x, y: yPosition(forValue: current.value)) + let cursorDate = interaction.scrollPosition.addingTimeInterval( + interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) + ) + let proposals = [ + tappedAnchor(at: laneProbe, viewportWidth: viewportWidth), + selectionAnchor(for: cursorDate, captureWindow: 0), + ].compactMap { anchor in + anchor.map { + BGChartHorizontalScrubCandidate( + value: $0, + plotX: xPosition(for: $0.date, viewportWidth: viewportWidth) + ) + } + } + + selection = horizontallyAdvancedBGChartScrubSelection( + current: BGChartHorizontalScrubCandidate( + value: current, + plotX: xPosition(for: current.date, viewportWidth: viewportWidth) + ), + proposals: proposals, + cursorX: location.x, + captureRadius: BGChartConfig.tapHitRadius + ) + } else { + // The first contact stays fully 2D so overlapping event types can + // still be selected by touching their visible symbol. + selection = tappedAnchor(at: location, viewportWidth: viewportWidth) + } // A featherlight tick whenever the finger moves onto a different mark. if let anchor = selection, anchor.date != lastHapticAnchorDate { diff --git a/Tests/Charts/BGChartScrubAttachmentTests.swift b/Tests/Charts/BGChartScrubAttachmentTests.swift index d82f46ea8..cd4ae5832 100644 --- a/Tests/Charts/BGChartScrubAttachmentTests.swift +++ b/Tests/Charts/BGChartScrubAttachmentTests.swift @@ -6,60 +6,113 @@ import CoreGraphics import Testing struct BGChartScrubAttachmentTests { - @Test("initial scrub uses the finger's full position") - func unattachedProbeUsesFingerPosition() { - let finger = CGPoint(x: 120, y: 280) + private enum Event: Equatable { + case smb(Int) + case bg(Int) + case cob(Int) + } + + @Test("closer BG wins while an SMB remains inside the capture radius") + func closerBGBeatsNearbySMB() { + let selected = resolve( + current: candidate(.smb(1), x: 125), + proposals: [candidate(.smb(1), x: 125), candidate(.bg(140), x: 140)], + cursorX: 140 + ) - #expect(bgChartScrubProbeLocation(fingerLocation: finger, trackedPlotY: nil) == finger) + #expect(selected == .bg(140)) } - @Test("attached scrub follows finger horizontally while ignoring vertical movement") - func attachedProbeUsesTrackedPlotHeight() { - let movedFinger = CGPoint(x: 175, y: 20) + @Test("scrub visits the BG entries between two SMBs") + func visitsBGsBetweenSMBs() { + let candidates = [ + candidate(.smb(1), x: 100), + candidate(.bg(120), x: 120), + candidate(.bg(140), x: 140), + candidate(.bg(160), x: 160), + candidate(.smb(2), x: 180), + ] + var current = candidates[0] + var selected: [Event] = [] - #expect( - bgChartScrubProbeLocation( - fingerLocation: movedFinger, - trackedPlotY: 240 - ) == CGPoint(x: 175, y: 240) - ) + for cursorX in [100, 120, 140, 160, 180] as [CGFloat] { + let value = resolve(current: current, proposals: candidates, cursorX: cursorX) + selected.append(value) + current = candidates.first(where: { $0.value == value })! + } + + #expect(selected == [.smb(1), .bg(120), .bg(140), .bg(160), .smb(2)]) } - @Test("tracked height advances with the newly selected point") - func probeFollowsUpdatedPlotHeight() { - let finger = CGPoint(x: 210, y: 350) + @Test("scrub visits the same entries in reverse") + func visitsBGsInReverse() { + let candidates = [ + candidate(.smb(1), x: 100), + candidate(.bg(120), x: 120), + candidate(.bg(140), x: 140), + candidate(.bg(160), x: 160), + candidate(.smb(2), x: 180), + ] + var current = candidates[4] + var selected: [Event] = [] + + for cursorX in [180, 160, 140, 120, 100] as [CGFloat] { + let value = resolve(current: current, proposals: candidates, cursorX: cursorX) + selected.append(value) + current = candidates.first(where: { $0.value == value })! + } + + #expect(selected == [.smb(2), .bg(160), .bg(140), .bg(120), .smb(1)]) + } + + @Test("candidate order cannot hide the closest entry") + func candidateOrderDoesNotMatter() { + let current = candidate(.smb(1), x: 100) + let proposals = [candidate(.smb(2), x: 145), candidate(.bg(140), x: 140)] + + #expect(resolve(current: current, proposals: proposals, cursorX: 140) == .bg(140)) + #expect(resolve(current: current, proposals: Array(proposals.reversed()), cursorX: 140) == .bg(140)) + } + + @Test("an exact horizontal tie preserves the attached event type") + func tiePreservesCurrentEvent() { + let current = candidate(.cob(20), x: 140) #expect( - bgChartScrubProbeLocation( - fingerLocation: finger, - trackedPlotY: 195 - ) == CGPoint(x: 210, y: 195) - ) - #expect( - bgChartScrubProbeLocation( - fingerLocation: finger, - trackedPlotY: 170 - ) == CGPoint(x: 210, y: 170) + resolve( + current: current, + proposals: [candidate(.bg(140), x: 140)], + cursorX: 140 + ) == .cob(20) ) } - @Test("temporary misses retain the attached selection") - func missRetainsCurrentSelection() { + @Test("a true horizontal gap retains the attached entry") + func gapRetainsCurrentEvent() { #expect( - retainedBGChartScrubSelection( - current: "current SMB", - proposed: nil - ) == "current SMB" + resolve( + current: candidate(.bg(100), x: 100), + proposals: [candidate(.bg(160), x: 160)], + cursorX: 200 + ) == .bg(100) ) } - @Test("a newly resolved point advances the selection") - func proposalReplacesCurrentSelection() { - #expect( - retainedBGChartScrubSelection( - current: "current BG", - proposed: "next BG" - ) == "next BG" + private func candidate(_ value: Event, x: CGFloat) -> BGChartHorizontalScrubCandidate { + BGChartHorizontalScrubCandidate(value: value, plotX: x) + } + + private func resolve( + current: BGChartHorizontalScrubCandidate, + proposals: [BGChartHorizontalScrubCandidate], + cursorX: CGFloat, + radius: CGFloat = 30 + ) -> Event { + horizontallyAdvancedBGChartScrubSelection( + current: current, + proposals: proposals, + cursorX: cursorX, + captureRadius: radius ) } } From 01743282921bdda50f0eed60e63033b0fc81dfbc Mon Sep 17 00:00:00 2001 From: aug0211 <659845+aug0211@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:32:04 -0400 Subject: [PATCH 4/4] Keep chart treatments reachable while scrubbing --- .../Charts/BGChartScrubAttachment.swift | 18 +++++++ LoopFollow/Charts/BGChartView.swift | 49 ++++++++++++++++++- .../Charts/BGChartScrubAttachmentTests.swift | 47 ++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Charts/BGChartScrubAttachment.swift b/LoopFollow/Charts/BGChartScrubAttachment.swift index 190ec0468..28ac1ca8e 100644 --- a/LoopFollow/Charts/BGChartScrubAttachment.swift +++ b/LoopFollow/Charts/BGChartScrubAttachment.swift @@ -8,6 +8,24 @@ struct BGChartHorizontalScrubCandidate { let plotX: CGFloat } +func nearestBGChartHorizontalScrubCandidate( + in candidates: [BGChartHorizontalScrubCandidate], + to cursorX: CGFloat, + captureRadius: CGFloat +) -> BGChartHorizontalScrubCandidate? { + var nearest: BGChartHorizontalScrubCandidate? + var nearestDistance = CGFloat.greatestFiniteMagnitude + + for candidate in candidates { + let distance = abs(candidate.plotX - cursorX) + guard distance <= captureRadius, distance < nearestDistance else { continue } + nearest = candidate + nearestDistance = distance + } + + return nearest +} + func horizontallyAdvancedBGChartScrubSelection( current: BGChartHorizontalScrubCandidate, proposals: [BGChartHorizontalScrubCandidate], diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 48308d1b6..51aa66c4e 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -533,8 +533,12 @@ private struct MainBGChart: View { interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) ) let proposals = [ + nearestTreatmentScrubAnchor(atViewportX: location.x, viewportWidth: viewportWidth), tappedAnchor(at: laneProbe, viewportWidth: viewportWidth), - selectionAnchor(for: cursorDate, captureWindow: 0), + // Treatments are proposed independently above. A negative + // window keeps this dense-series lookup from rebuilding the + // old multi-treatment popup at an exact timestamp. + selectionAnchor(for: cursorDate, captureWindow: -1), ].compactMap { anchor in anchor.map { BGChartHorizontalScrubCandidate( @@ -567,6 +571,49 @@ private struct MainBGChart: View { } } + /// Finds the closest treatment by rendered X only, then resolves its pill + /// through the normal 2-D hit test at the mark's exact plotted position. + /// This keeps sparse SMB, carb, and bolus marks reachable after the scrub + /// has detached vertically from the finger. + private func nearestTreatmentScrubAnchor( + atViewportX cursorX: CGFloat, + viewportWidth: CGFloat + ) -> SelectionAnchor? { + let groups = [ + model.boluses, + model.carbs, + model.smbs, + model.bgChecks, + model.notes, + model.suspends, + model.resumes, + model.sensorStarts, + ] + let candidates = groups.flatMap { group in + group.map { treatment in + BGChartHorizontalScrubCandidate( + value: treatment, + plotX: xPosition(for: treatment.drawnDate, viewportWidth: viewportWidth) + ) + } + } + guard let nearest = nearestBGChartHorizontalScrubCandidate( + in: candidates, + to: cursorX, + captureRadius: BGChartConfig.tapHitRadius + ) else { + return nil + } + + return tappedAnchor( + at: CGPoint( + x: nearest.plotX, + y: yPosition(forValue: nearest.value.sgv) + ), + viewportWidth: viewportWidth + ) + } + /// Deceleration after a flick. Mutates only scrollPosition (a transform), /// so each frame costs a GPU translation — same profile as live panning. private func startMomentum(velocitySecondsPerSecond initialVelocity: TimeInterval) { diff --git a/Tests/Charts/BGChartScrubAttachmentTests.swift b/Tests/Charts/BGChartScrubAttachmentTests.swift index cd4ae5832..84bee631d 100644 --- a/Tests/Charts/BGChartScrubAttachmentTests.swift +++ b/Tests/Charts/BGChartScrubAttachmentTests.swift @@ -8,8 +8,11 @@ import Testing struct BGChartScrubAttachmentTests { private enum Event: Equatable { case smb(Int) + case carb(Int) + case bolus(Int) case bg(Int) case cob(Int) + case composite } @Test("closer BG wins while an SMB remains inside the capture radius") @@ -74,6 +77,17 @@ struct BGChartScrubAttachmentTests { #expect(resolve(current: current, proposals: Array(proposals.reversed()), cursorX: 140) == .bg(140)) } + @Test("an explicit treatment wins a same-position composite proposal") + func explicitTreatmentWinsCompositeTie() { + let current = candidate(.bg(100), x: 100) + let proposals = [ + candidate(.bolus(2), x: 140), + candidate(.composite, x: 140), + ] + + #expect(resolve(current: current, proposals: proposals, cursorX: 140) == .bolus(2)) + } + @Test("an exact horizontal tie preserves the attached event type") func tiePreservesCurrentEvent() { let current = candidate(.cob(20), x: 140) @@ -98,6 +112,27 @@ struct BGChartScrubAttachmentTests { ) } + @Test("each sparse treatment kind remains reachable by horizontal position") + func treatmentKindsRemainReachable() { + let candidates = [ + candidate(.smb(1), x: 100), + candidate(.carb(20), x: 130), + candidate(.bolus(2), x: 160), + ] + + #expect(nearest(in: candidates, cursorX: 100)?.value == .smb(1)) + #expect(nearest(in: candidates, cursorX: 130)?.value == .carb(20)) + #expect(nearest(in: candidates, cursorX: 160)?.value == .bolus(2)) + } + + @Test("a treatment outside the horizontal capture radius is ignored") + func distantTreatmentIsIgnored() { + let candidates = [candidate(.bolus(1), x: 100)] + + #expect(nearest(in: candidates, cursorX: 131) == nil) + #expect(nearest(in: candidates, cursorX: 130)?.value == .bolus(1)) + } + private func candidate(_ value: Event, x: CGFloat) -> BGChartHorizontalScrubCandidate { BGChartHorizontalScrubCandidate(value: value, plotX: x) } @@ -115,4 +150,16 @@ struct BGChartScrubAttachmentTests { captureRadius: radius ) } + + private func nearest( + in candidates: [BGChartHorizontalScrubCandidate], + cursorX: CGFloat, + radius: CGFloat = 30 + ) -> BGChartHorizontalScrubCandidate? { + nearestBGChartHorizontalScrubCandidate( + in: candidates, + to: cursorX, + captureRadius: radius + ) + } }