diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 9b451ac6a..d5a807082 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -111,6 +111,8 @@ final class BGChartModel: ObservableObject { } @Published var bg: [BGPoint] = [] + /// Scrub timeline on the BG cadence; rebuilt together with `bg`. + private(set) var scrubSlots = BGChartScrubSlots(readingDates: []) @Published var bgRuns: [BGRun] = [] @Published var yesterday: [BGPoint] = [] @Published var prediction: [BGPoint] = [] @@ -430,6 +432,7 @@ final class BGChartModel: ObservableObject { let maxDisplay = globalVariables.maxDisplayGlucose func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) } + scrubSlots = BGChartScrubSlots(readingDates: vc.bgData.map { Date(timeIntervalSince1970: $0.date) }) bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) } bgRuns = Self.makeRuns(bg) diff --git a/LoopFollow/Charts/BGChartScrubSlots.swift b/LoopFollow/Charts/BGChartScrubSlots.swift new file mode 100644 index 000000000..6bd1e9887 --- /dev/null +++ b/LoopFollow/Charts/BGChartScrubSlots.swift @@ -0,0 +1,148 @@ +// LoopFollow +// BGChartScrubSlots.swift + +import Foundation + +/// Five-minute grid, phase-locked to the readings, that the scrub indicator +/// snaps to. +/// +/// The grid is walked backwards from the newest reading. Each step lands on +/// the reading nearest the expected mark when one lies within half a cadence +/// of it, and on a virtual mark otherwise, so drift is absorbed and gaps are +/// crossed at exactly the cadence. The grid continues before the first and +/// after the last mark. Each mark owns the time between the midpoints to its +/// neighbours, so the blocks tile the timeline and every reading and +/// treatment belongs to exactly one mark. +struct BGChartScrubSlots { + struct Slot: Equatable { + let date: Date + /// Index into `readingDates` for a mark placed on a reading; nil for a + /// virtual mark. + let readingIndex: Int? + /// Owned time, inclusive at the start and exclusive at the end. + let blockStart: Date + let blockEnd: Date + + var isReading: Bool { readingIndex != nil } + + func contains(_ date: Date) -> Bool { + date >= blockStart && date < blockEnd + } + } + + static let cadence: TimeInterval = 5 * 60 + + /// Every reading timestamp, ascending. + let readingDates: [Date] + /// Grid marks inside the data range, ascending. + private let slotDates: [Date] + private let slotReadingIndices: [Int?] + + init(readingDates: [Date]) { + let sorted = readingDates.sorted() + self.readingDates = sorted + + var dates: [Date] = [] + var indices: [Int?] = [] + if let first = sorted.first, let newest = sorted.last { + let cadence = Self.cadence + var cursor = newest + var cursorIndex = sorted.count - 1 + dates.append(cursor) + indices.append(cursorIndex) + while first < cursor.addingTimeInterval(-cadence / 2) { + let target = cursor.addingTimeInterval(-cadence) + if let hit = Self.nearestReading(in: sorted, before: cursorIndex, to: target, within: cadence / 2) { + cursor = sorted[hit] + cursorIndex = hit + dates.append(cursor) + indices.append(hit) + } else { + cursor = target + dates.append(cursor) + indices.append(nil) + } + } + dates.reverse() + indices.reverse() + } + slotDates = dates + slotReadingIndices = indices + } + + /// Index of the reading nearest `target` among those strictly before index + /// `limit`, if it lies within `tolerance` of the target. + private static func nearestReading(in sorted: [Date], before limit: Int, to target: Date, within tolerance: TimeInterval) -> Int? { + guard limit > 0 else { return nil } + // First index in 0 ..< limit whose date is >= target. + var low = 0 + var high = limit + while low < high { + let mid = (low + high) / 2 + if sorted[mid] < target { low = mid + 1 } else { high = mid } + } + var best: Int? + for candidate in [low - 1, low] where candidate >= 0 && candidate < limit { + let distance = abs(sorted[candidate].timeIntervalSince(target)) + if distance <= tolerance, best.map({ distance < abs(sorted[$0].timeIntervalSince(target)) }) ?? true { + best = candidate + } + } + return best + } + + /// The mark whose block contains `date`. + func slot(containing date: Date) -> Slot { + let cadence = Self.cadence + guard let firstSlot = slotDates.first, let lastSlot = slotDates.last else { + return virtualSlot(origin: Date(timeIntervalSince1970: 0), nearestTo: date) + } + if date < firstSlot.addingTimeInterval(-cadence / 2) { + return virtualSlot(origin: firstSlot, nearestTo: date) + } + if date >= lastSlot.addingTimeInterval(cadence / 2) { + return virtualSlot(origin: lastSlot, nearestTo: date) + } + + // Index of the last mark at or before `date`; the midpoint to its + // successor decides between the two. + var low = 0 + var high = slotDates.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if slotDates[mid] <= date { low = mid } else { high = mid - 1 } + } + var i = low + if date < firstSlot { + i = 0 + } else if i + 1 < slotDates.count, date >= midpoint(slotDates[i], slotDates[i + 1]) { + i += 1 + } + return gridSlot(at: i) + } + + private func gridSlot(at i: Int) -> Slot { + let cadence = Self.cadence + let date = slotDates[i] + let blockStart = i > 0 ? midpoint(slotDates[i - 1], date) : date.addingTimeInterval(-cadence / 2) + let blockEnd = i + 1 < slotDates.count ? midpoint(date, slotDates[i + 1]) : date.addingTimeInterval(cadence / 2) + return Slot(date: date, readingIndex: slotReadingIndices[i], blockStart: blockStart, blockEnd: blockEnd) + } + + /// Virtual mark on the grid anchored at `origin`; a halfway date belongs + /// to the later mark, matching the half-open blocks. + private func virtualSlot(origin: Date, nearestTo date: Date) -> Slot { + let cadence = Self.cadence + let k = (date.timeIntervalSince(origin) / cadence + 0.5).rounded(.down) + return Slot( + date: origin.addingTimeInterval(k * cadence), + readingIndex: nil, + blockStart: origin.addingTimeInterval((k - 0.5) * cadence), + blockEnd: origin.addingTimeInterval((k + 0.5) * cadence) + ) + } + + private func midpoint(_ a: Date, _ b: Date) -> Date { + a.addingTimeInterval(b.timeIntervalSince(a) / 2) + } +} diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9098bd123..51b17ec3f 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -44,15 +44,6 @@ private enum BGChartConfig { /// How long after the last navigation in history before a data tick pulls /// the chart back to "now". static let autoFollowPause: TimeInterval = 5 * 60 - /// Max distance between the scrub date and an anchor for it to be selected. - static let selectionTolerance: TimeInterval = 20 * 60 - /// Half-width (pt) of the scrub capture band: treatments whose symbol is - /// within this screen distance of the finger join the pill alongside the - /// (ever-present) nearest BG reading. - static let scrubCaptureRadius: CGFloat = 22 - /// Time cap on the capture band, so wide zooms — where a finger-width - /// covers hours — don't sweep far-away treatments into the pill. - static let scrubCaptureMaxSeconds: TimeInterval = 5 * 60 /// Screen-space radius (pt) within which a tap selects a mark. static let tapHitRadius: CGFloat = 30 } @@ -532,9 +523,8 @@ private struct MainBGChart: View { 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 { + // A featherlight tick whenever the indicator snaps to a different slot. + if let anchor = selectionAnchor(for: date), anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() @@ -688,14 +678,14 @@ private struct MainBGChart: View { let texts: [String] } - /// Feeds every treatment mark to `body` as (drawnDate, value, pillText). - /// Single source for both the scrub lookup and the tap hit test. - private func forEachTreatmentAnchor(_ body: (Date, Double, String) -> Void) { + /// Feeds every treatment mark to `body`. Single source for both the scrub + /// lookup and the tap hit test. + private func forEachTreatmentAnchor(_ body: (BGChartModel.TreatmentPoint) -> Void) { for group in [model.boluses, model.carbs, model.smbs, model.bgChecks, model.notes, model.suspends, model.resumes, model.sensorStarts] { for t in group { - body(t.drawnDate, t.sgv, t.pillText) + body(t) } } } @@ -739,76 +729,45 @@ private struct MainBGChart: View { return nil } - /// Seconds of chart time covered by `scrubCaptureRadius` at the current - /// zoom, bounded by `scrubCaptureMaxSeconds`. - private func scrubCaptureWindow(viewportWidth: CGFloat) -> TimeInterval { - min( - BGChartConfig.scrubCaptureMaxSeconds, - TimeInterval(BGChartConfig.scrubCaptureRadius / viewportWidth) * interaction.visibleSeconds - ) - } - - /// Scrub lookup (time-only). Collects everything under the finger instead - /// of picking a single winner: every treatment inside the capture window - /// joins the pill, and the nearest BG reading always does — so treatments - /// and glucose readings can never hide one another. The indicator snaps - /// to the nearest collected item; the pill stacks them all (treatments in - /// drawn order, BG last). - private func selectionAnchor(for selected: Date, captureWindow: TimeInterval) -> SelectionAnchor? { - struct Item { - let date: Date - let value: Double - let text: String - let distance: TimeInterval - } - - var captured: [Item] = [] - var nearestTreatment: Item? - forEachTreatmentAnchor { date, value, text in - let item = Item(date: date, value: value, text: text, distance: abs(date.timeIntervalSince(selected))) - if item.distance <= captureWindow { - captured.append(item) - } - if item.distance < (nearestTreatment?.distance ?? .greatestFiniteMagnitude) { - nearestTreatment = item + /// Scrub lookup (time-only). The finger resolves to the grid mark whose + /// block contains the scrub time (see BGChartScrubSlots); the indicator + /// stands on the mark. The pill stacks every treatment in the block, then + /// every BG reading in it, then any band at the mark, so it is constant + /// across the block. The indicator's height comes from the reading nearest + /// the mark, else the nearest treatment, else the band; an empty block + /// shows nothing. + private func selectionAnchor(for selected: Date) -> SelectionAnchor? { + let slot = model.scrubSlots.slot(containing: selected) + let mark = slot.date + + var treatments: [BGChartModel.TreatmentPoint] = [] + forEachTreatmentAnchor { t in + if slot.contains(t.date) { treatments.append(t) } + } + treatments.sort { $0.date < $1.date } + let readings = model.bg.filter { slot.contains($0.date) } + + var texts = treatments.map(\.pillText) + readings.map(bgPillText) + let bandTexts = bandPillTexts(at: mark) + texts += bandTexts + + func distanceToMark(_ date: Date) -> TimeInterval { abs(date.timeIntervalSince(mark)) } + + var value: Double? + if let reading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = reading.value + } else if let nearest = treatments.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = nearest.sgv + } else if !bandTexts.isEmpty { + if let band = model.overrides.first(where: { mark >= $0.start && mark <= $0.end }) + ?? model.tempTargets.first(where: { mark >= $0.start && mark <= $0.end }) + { + value = (band.yTop + band.yBottom) / 2 } } - captured.sort { $0.date < $1.date } - var nearestBG: Item? - for p in model.bg { - let d = abs(p.date.timeIntervalSince(selected)) - if d < (nearestBG?.distance ?? .greatestFiniteMagnitude) { - nearestBG = Item(date: p.date, value: p.value, text: bgPillText(for: p), distance: d) - } - } - - var items = captured - if let nearestBG, nearestBG.distance <= BGChartConfig.selectionTolerance { - items.append(nearestBG) - } - if let primary = items.min(by: { $0.distance < $1.distance }) { - 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 { - 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 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) - } - for band in model.tempTargets where selected >= band.start && selected <= band.end { - let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) - } - - return nil + guard let value else { return nil } + return SelectionAnchor(date: mark, value: value, texts: texts) } /// Tap hit test (screen-space, 2D). Treatments take priority, then BG @@ -829,7 +788,7 @@ private struct MainBGChart: View { } } - forEachTreatmentAnchor(consider) + forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } if best == nil { for p in model.bg { consider(p.date, p.value, bgPillText(for: p)) @@ -854,9 +813,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? { + private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { - return selectionAnchor(for: selected, captureWindow: scrubCaptureWindow(viewportWidth: viewportWidth)) + return selectionAnchor(for: selected) } return tapped } @@ -922,7 +881,7 @@ private struct MainBGChart: View { /// there is no manual line splitting. @ViewBuilder private func selectionOverlay(viewportWidth: CGFloat) -> some View { - if plotFrame.height > 0, let anchor = activeAnchor(viewportWidth: viewportWidth) { + if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { let y = yPosition(forValue: anchor.value) diff --git a/Tests/Charts/BGChartScrubSlotsTests.swift b/Tests/Charts/BGChartScrubSlotsTests.swift new file mode 100644 index 000000000..cb4b90360 --- /dev/null +++ b/Tests/Charts/BGChartScrubSlotsTests.swift @@ -0,0 +1,156 @@ +// LoopFollow +// BGChartScrubSlotsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct BGChartScrubSlotsTests { + private let origin = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(_ minutes: Double) -> Date { + origin.addingTimeInterval(minutes * 60) + } + + private func minutes(_ date: Date) -> Double { + date.timeIntervalSince(origin) / 60 + } + + private func slot(_ slot: BGChartScrubSlots.Slot, isAt minute: Double) -> Bool { + abs(minutes(slot.date) - minute) < 0.001 + } + + @Test("five-minute readings are marks that own the time up to the midpoints") + func denseReadingBlocks() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10, 15].map(at)) + + let second = slots.slot(containing: at(6)) + #expect(second.readingIndex == 1) + #expect(second.date == at(5)) + #expect(minutes(second.blockStart) == 2.5) + #expect(minutes(second.blockEnd) == 7.5) + + #expect(slots.slot(containing: at(7.4)).readingIndex == 1) + #expect(slots.slot(containing: at(7.5)).readingIndex == 2) + } + + @Test("one-minute readings snap to every fifth reading, anchored at the newest") + func oneMinuteDataSnapsToFiveMinutes() { + let slots = BGChartScrubSlots(readingDates: stride(from: 0.0, through: 22.0, by: 1.0).map(at)) + + #expect(slot(slots.slot(containing: at(22)), isAt: 22)) + #expect(slot(slots.slot(containing: at(16)), isAt: 17)) + #expect(slot(slots.slot(containing: at(13)), isAt: 12)) + #expect(slot(slots.slot(containing: at(1)), isAt: 2)) + + let seventeen = slots.slot(containing: at(16)) + #expect(seventeen.isReading) + #expect(minutes(seventeen.blockStart) == 14.5) + #expect(minutes(seventeen.blockEnd) == 19.5) + } + + @Test("sensor drift keeps every reading on the grid") + func driftFollowsReadings() { + let slots = BGChartScrubSlots(readingDates: [0, 5.1, 10.2, 15.3, 20.4].map(at)) + for (index, minute) in [0, 5.1, 10.2, 15.3, 20.4].enumerated() { + let mark = slots.slot(containing: at(minute)) + #expect(mark.readingIndex == index) + #expect(slot(mark, isAt: minute)) + } + } + + @Test("a straggler close to a grid reading joins its block") + func stragglerJoinsBlock() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 5.2, 10].map(at)) + + let mark = slots.slot(containing: at(5.2)) + #expect(mark.date == at(5)) + #expect(mark.contains(at(5.2))) + #expect(slots.slot(containing: at(10)).date == at(10)) + } + + @Test("a gap is crossed at exactly the cadence from the reading that ends it") + func gapUsesVirtualCadence() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10, 33, 38, 43].map(at)) + + for minute in [18.0, 23.0, 28.0] { + let mark = slots.slot(containing: at(minute + 1)) + #expect(slot(mark, isAt: minute)) + #expect(mark.readingIndex == nil) + #expect(mark.blockEnd.timeIntervalSince(mark.blockStart) == 300) + } + + // The virtual mark next to the reading that resumes the data shares + // the leftover with it at the midpoint. + let edge = slots.slot(containing: at(14)) + #expect(slot(edge, isAt: 13)) + #expect(minutes(edge.blockStart) == 11.5) + #expect(minutes(edge.blockEnd) == 15.5) + + let resumed = slots.slot(containing: at(9)) + #expect(resumed.readingIndex == 2) + #expect(minutes(resumed.blockStart) == 7.5) + #expect(minutes(resumed.blockEnd) == 11.5) + } + + @Test("a reading just off the grid is used instead of a virtual mark") + func nearReadingBeatsVirtualMark() { + let slots = BGChartScrubSlots(readingDates: [0, 7, 30, 35].map(at)) + + #expect(slot(slots.slot(containing: at(24)), isAt: 25)) + #expect(slot(slots.slot(containing: at(19)), isAt: 20)) + #expect(slot(slots.slot(containing: at(14)), isAt: 15)) + #expect(slot(slots.slot(containing: at(11)), isAt: 10)) + + let seven = slots.slot(containing: at(6)) + #expect(seven.readingIndex == 1) + #expect(slot(seven, isAt: 7)) + + #expect(slots.slot(containing: at(3)).readingIndex == 0) + } + + @Test("the grid continues at the cadence beyond the first and last marks") + func gridExtendsBeyondReadings() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10].map(at)) + + let last = slots.slot(containing: at(12)) + #expect(last.readingIndex == 2) + #expect(minutes(last.blockEnd) == 12.5) + + let future = slots.slot(containing: at(23)) + #expect(future.readingIndex == nil) + #expect(slot(future, isAt: 25)) + + let past = slots.slot(containing: at(-9)) + #expect(past.readingIndex == nil) + #expect(slot(past, isAt: -10)) + } + + @Test("without readings every instant still resolves to a virtual mark") + func noReadingsUsesVirtualGrid() { + let slots = BGChartScrubSlots(readingDates: []) + let mark = slots.slot(containing: at(7)) + #expect(mark.readingIndex == nil) + #expect(mark.contains(at(7))) + #expect(mark.blockEnd.timeIntervalSince(mark.blockStart) == 300) + } + + @Test("blocks tile the timeline with no overlaps or holes") + func blocksTileTimeline() { + let slots = BGChartScrubSlots(readingDates: [0, 1, 2, 5, 7, 10, 33, 38, 43, 60].map(at)) + var previous: BGChartScrubSlots.Slot? + + for tenth in stride(from: -20.0, through: 80.0, by: 0.1) { + let probe = at(tenth) + let mark = slots.slot(containing: probe) + #expect(mark.contains(probe), "\(tenth) min not inside its own block") + if let previous, previous != mark { + #expect(abs(mark.blockStart.timeIntervalSince(previous.blockEnd)) < 1e-6, "hole or overlap at \(tenth) min") + #expect(previous.date < mark.date) + let spacing = mark.date.timeIntervalSince(previous.date) + #expect(spacing >= 150 && spacing <= 450, "mark spacing \(spacing) s at \(tenth) min") + } + previous = mark + } + } +}