diff --git a/LoopFollow/Charts/BGChartScrubAttachment.swift b/LoopFollow/Charts/BGChartScrubAttachment.swift new file mode 100644 index 000000000..28ac1ca8e --- /dev/null +++ b/LoopFollow/Charts/BGChartScrubAttachment.swift @@ -0,0 +1,46 @@ +// LoopFollow +// BGChartScrubAttachment.swift + +import CoreGraphics + +struct BGChartHorizontalScrubCandidate { + let value: Value + 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], + 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 9098bd123..51aa66c4e 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,26 +521,99 @@ 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) { + 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 = [ + nearestTreatmentScrubAnchor(atViewportX: location.x, viewportWidth: viewportWidth), + tappedAnchor(at: laneProbe, viewportWidth: viewportWidth), + // 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( + 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 { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() } } + /// 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) { @@ -854,9 +927,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 } diff --git a/Tests/Charts/BGChartScrubAttachmentTests.swift b/Tests/Charts/BGChartScrubAttachmentTests.swift new file mode 100644 index 000000000..84bee631d --- /dev/null +++ b/Tests/Charts/BGChartScrubAttachmentTests.swift @@ -0,0 +1,165 @@ +// LoopFollow +// BGChartScrubAttachmentTests.swift + +import CoreGraphics +@testable import LoopFollow +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") + 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(selected == .bg(140)) + } + + @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] = [] + + 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("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 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) + + #expect( + resolve( + current: current, + proposals: [candidate(.bg(140), x: 140)], + cursorX: 140 + ) == .cob(20) + ) + } + + @Test("a true horizontal gap retains the attached entry") + func gapRetainsCurrentEvent() { + #expect( + resolve( + current: candidate(.bg(100), x: 100), + proposals: [candidate(.bg(160), x: 160)], + cursorX: 200 + ) == .bg(100) + ) + } + + @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) + } + + private func resolve( + current: BGChartHorizontalScrubCandidate, + proposals: [BGChartHorizontalScrubCandidate], + cursorX: CGFloat, + radius: CGFloat = 30 + ) -> Event { + horizontallyAdvancedBGChartScrubSelection( + current: current, + proposals: proposals, + cursorX: cursorX, + captureRadius: radius + ) + } + + private func nearest( + in candidates: [BGChartHorizontalScrubCandidate], + cursorX: CGFloat, + radius: CGFloat = 30 + ) -> BGChartHorizontalScrubCandidate? { + nearestBGChartHorizontalScrubCandidate( + in: candidates, + to: cursorX, + captureRadius: radius + ) + } +}