Skip to content
Open
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
46 changes: 46 additions & 0 deletions LoopFollow/Charts/BGChartScrubAttachment.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// LoopFollow
// BGChartScrubAttachment.swift

import CoreGraphics

struct BGChartHorizontalScrubCandidate<Value> {
let value: Value
let plotX: CGFloat
}

func nearestBGChartHorizontalScrubCandidate<Value>(
in candidates: [BGChartHorizontalScrubCandidate<Value>],
to cursorX: CGFloat,
captureRadius: CGFloat
) -> BGChartHorizontalScrubCandidate<Value>? {
var nearest: BGChartHorizontalScrubCandidate<Value>?
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<Value>(
current: BGChartHorizontalScrubCandidate<Value>,
proposals: [BGChartHorizontalScrubCandidate<Value>],
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
}
105 changes: 89 additions & 16 deletions LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -439,7 +439,7 @@ private struct MainBGChart: View {
}

if isInspectLatched {
updateSelection(atViewportX: value.location.x, viewportWidth: viewportWidth)
updateSelection(at: value.location, viewportWidth: viewportWidth)
return
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
165 changes: 165 additions & 0 deletions Tests/Charts/BGChartScrubAttachmentTests.swift
Original file line number Diff line number Diff line change
@@ -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<Event> {
BGChartHorizontalScrubCandidate(value: value, plotX: x)
}

private func resolve(
current: BGChartHorizontalScrubCandidate<Event>,
proposals: [BGChartHorizontalScrubCandidate<Event>],
cursorX: CGFloat,
radius: CGFloat = 30
) -> Event {
horizontallyAdvancedBGChartScrubSelection(
current: current,
proposals: proposals,
cursorX: cursorX,
captureRadius: radius
)
}

private func nearest(
in candidates: [BGChartHorizontalScrubCandidate<Event>],
cursorX: CGFloat,
radius: CGFloat = 30
) -> BGChartHorizontalScrubCandidate<Event>? {
nearestBGChartHorizontalScrubCandidate(
in: candidates,
to: cursorX,
captureRadius: radius
)
}
}
Loading