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
3 changes: 3 additions & 0 deletions LoopFollow/Charts/BGChartModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down Expand Up @@ -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)

Expand Down
148 changes: 148 additions & 0 deletions LoopFollow/Charts/BGChartScrubSlots.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
133 changes: 46 additions & 87 deletions LoopFollow/Charts/BGChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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))
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading