BGChartView.swift 66 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599
  1. // LoopFollow
  2. // BGChartView.swift
  3. import Charts
  4. import SwiftUI
  5. /// Shared generator for the light tick emitted when a scrub lands on a new
  6. /// anchor. File-scoped so it stays prepared across ticks instead of being
  7. /// re-created per body evaluation.
  8. private let scrubHaptic = UISelectionFeedbackGenerator()
  9. private enum BGChartConfig {
  10. /// Tightest pinch-in zoom.
  11. static let minVisibleSeconds: TimeInterval = 15 * 60
  12. /// Widest pinch-out zoom.
  13. static let maxVisibleSeconds: TimeInterval = 24 * 3600
  14. /// Double-tap cycles the visible window through these presets.
  15. static let zoomPresets: [TimeInterval] = [3600, 3 * 3600, 6 * 3600, 12 * 3600, 24 * 3600]
  16. /// Anchor of the geometric grid pinch commits quantize to.
  17. static let zoomGridBaseSeconds: TimeInterval = 3600
  18. /// Geometric grid for pinch commits (~4 % per step). Every committed zoom
  19. /// step re-lays the canvas, so this bounds a halving of the visible window
  20. /// to roughly 18 re-layouts instead of hundreds.
  21. static let zoomStepRatio: Double = 1.04
  22. /// Live pinch previews as a transform; once the stretch drifts past this
  23. /// ratio a crisp re-layout is committed mid-gesture, bounding distortion.
  24. static let pinchCommitScaleDrift: Double = 1.25
  25. /// Render window extends this many visible-windows beyond each visible edge.
  26. static let renderWindowPadFactor = 1.5
  27. /// Re-anchor when a visible edge gets within this fraction of a
  28. /// visible-window of the render window's edge.
  29. static let renderWindowMarginFactor = 0.5
  30. /// How far (pt) a one-finger touch may travel and still count as a
  31. /// stationary press-to-inspect; beyond this the touch becomes a pan.
  32. static let inspectMovementTolerance: CGFloat = 10
  33. /// How long (s) a one-finger touch must rest before inspect latches.
  34. static let inspectHoldDelay: TimeInterval = 0.2
  35. /// How close the visible window's trailing edge must sit to "now" before
  36. /// we treat the user as "at the latest data" and resume auto-following.
  37. static let followEdgeTolerance: TimeInterval = 90
  38. /// While following, "now" sits at this fraction of the visible window,
  39. /// leaving room on the right for predictions/cone.
  40. static let followNowFraction = 0.7
  41. /// How long after the last navigation in history before a data tick pulls
  42. /// the chart back to "now".
  43. static let autoFollowPause: TimeInterval = 5 * 60
  44. /// Max distance between the scrub date and an anchor for it to be selected.
  45. static let selectionTolerance: TimeInterval = 20 * 60
  46. /// Half-width (pt) of the scrub capture band: treatments whose symbol is
  47. /// within this screen distance of the finger join the pill alongside the
  48. /// (ever-present) nearest BG reading.
  49. static let scrubCaptureRadius: CGFloat = 22
  50. /// Time cap on the capture band, so wide zooms — where a finger-width
  51. /// covers hours — don't sweep far-away treatments into the pill.
  52. static let scrubCaptureMaxSeconds: TimeInterval = 5 * 60
  53. /// Screen-space radius (pt) within which a tap selects a mark.
  54. static let tapHitRadius: CGFloat = 30
  55. }
  56. /// Small y-domain headroom keeps the top axis label readable instead of
  57. /// pinning it to the chart edge.
  58. private func chartYDomainUpperBound(_ maxBG: Double) -> Double {
  59. let clampedMax = max(maxBG, 1)
  60. let topPadding = max(clampedMax * 0.02, 1)
  61. return clampedMax + topPadding
  62. }
  63. struct BGChartView: View {
  64. enum Config {
  65. case small
  66. case main
  67. }
  68. let model: BGChartModel
  69. let config: Config
  70. var body: some View {
  71. if config == .small {
  72. SmallBGChart(model: model, interaction: model.interaction)
  73. } else {
  74. MainBGChart(model: model, interaction: model.interaction)
  75. }
  76. }
  77. }
  78. // MARK: - Main chart shell (gestures, transforms, overlays)
  79. /// The interactive BG chart.
  80. ///
  81. /// Rendering/interaction strategy: the chart content is
  82. /// laid out ONCE per (data, zoom) change onto a wide fixed-width canvas
  83. /// covering a render window around the visible viewport. Panning translates
  84. /// that canvas with a pure `.offset` transform — the canvas is an Equatable
  85. /// child whose stored properties exclude the pan position, so SwiftUI skips
  86. /// its body during pans and momentum. Zoom is a pure-SwiftUI MagnifyGesture
  87. /// whose live preview is a `.scaleEffect(x:)` stretch anchored under the
  88. /// pinch centroid, committed on a geometric zoom grid. A one-finger press
  89. /// held stationary latches into inspect mode and scrubs a selection that is
  90. /// rendered by a shell overlay (never re-laying the canvas). Double-tap
  91. /// cycles zoom presets. No `.chartScrollableAxes`, no UIKit gesture hacks.
  92. private struct MainBGChart: View {
  93. @ObservedObject var model: BGChartModel
  94. @ObservedObject var interaction: BGChartInteraction
  95. @State private var didInitialize = false
  96. /// Rendered slice of the domain. The canvas covers only this window
  97. /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width
  98. /// and per-layout cost no matter how long the data domain grows.
  99. @State private var renderWindowStart = Date()
  100. @State private var renderWindowEnd = Date().addingTimeInterval(3600)
  101. /// Plot area of the static axis overlay, in shell coordinates. The
  102. /// selection overlay uses it for its value-to-pixel maps.
  103. @State private var plotFrame: CGRect = .zero
  104. /// Measured size of the visible selection pill (see PillSizePreferenceKey).
  105. @State private var pillSize: CGSize = .zero
  106. // Pinch state: live preview stretch plus the anchor captured at pinch
  107. // start so the zoom stays anchored under the pinch centroid.
  108. @State private var pinchScale: CGFloat = 1
  109. @State private var pinchAnchor: (
  110. visibleAtStart: TimeInterval,
  111. anchorDate: Date,
  112. anchorFraction: CGFloat
  113. )?
  114. /// Leading edge captured when a one-finger drag transitions into panning.
  115. @State private var panBaseline: Date?
  116. /// Date under the user's finger while inspecting, else nil.
  117. @State private var selection: Date?
  118. /// Anchor selected by tapping a mark; sticky until the user taps empty
  119. /// space, taps another mark, or starts a pan/zoom/inspect.
  120. @State private var tapped: SelectionAnchor?
  121. /// True once a held press has engaged inspect; from then on finger
  122. /// movement scrubs the selection instead of panning, until the finger lifts.
  123. @State private var isInspectLatched = false
  124. /// Most recent finger location, so the hold timer can place the selection
  125. /// even if the finger produced no further events after touch-down.
  126. @State private var lastTouchLocation: CGPoint?
  127. /// Armed at touch-down; fires after `inspectHoldDelay` and latches inspect
  128. /// if the touch is still down and stationary. A timer is required because
  129. /// DragGesture only reports *changes* — a perfectly still finger generates
  130. /// no events after touch-down.
  131. @State private var inspectHoldTask: Task<Void, Never>?
  132. @State private var touchDownTime: Date?
  133. /// Drives post-flick deceleration; cancelled by any new touch.
  134. @State private var momentumTask: Task<Void, Never>?
  135. /// Anchor date of the last haptic tick, so scrub jitter doesn't re-fire it.
  136. @State private var lastHapticAnchorDate: Date?
  137. /// While the user is (or recently was) navigating history, auto-return to
  138. /// "now" is paused until this instant. Refreshed by every scroll movement
  139. /// away from the live edge, cleared on return to it.
  140. @State private var autoFollowPausedUntil: Date?
  141. private var timeZoneForAxis: TimeZone {
  142. if Storage.shared.graphTimeZoneEnabled.value,
  143. let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
  144. {
  145. return tz
  146. }
  147. return .current
  148. }
  149. var body: some View {
  150. GeometryReader { geo in
  151. chart(viewport: geo.size)
  152. }
  153. .background(Color(.systemBackground))
  154. }
  155. private func chart(viewport: CGSize) -> some View {
  156. let viewportWidth = max(viewport.width, 1)
  157. let windowSeconds = max(renderWindowEnd.timeIntervalSince(renderWindowStart), 1)
  158. let canvasWidth = viewportWidth * CGFloat(windowSeconds / interaction.visibleSeconds)
  159. // Pixel offset of the canvas for the current leading-edge date.
  160. // Derived, not stored: re-anchoring the window recomputes it consistently.
  161. let canvasOffsetX = CGFloat(
  162. interaction.scrollPosition.timeIntervalSince(renderWindowStart) / windowSeconds
  163. ) * canvasWidth
  164. return ZStack(alignment: .topLeading) {
  165. BGChartCanvas(
  166. model: model,
  167. generation: model.generation,
  168. isSmall: false,
  169. windowStart: renderWindowStart,
  170. windowEnd: renderWindowEnd,
  171. canvasWidth: canvasWidth,
  172. height: viewport.height,
  173. visibleSeconds: interaction.visibleSeconds,
  174. timeZone: timeZoneForAxis
  175. )
  176. .equatable()
  177. .offset(x: -canvasOffsetX)
  178. .scaleEffect(
  179. x: pinchScale,
  180. y: 1,
  181. anchor: pinchScaleAnchor(viewportWidth: viewportWidth, canvasWidth: canvasWidth)
  182. )
  183. // Pinned y-axis labels (BG trailing, basal leading) over the
  184. // scrolling canvas. The canvas's own y-axis is hidden — its
  185. // trailing edge sits far off-screen on the wide canvas.
  186. StaticYAxisOverlay(generation: model.generation, maxBG: model.maxBG, maxBasal: model.maxBasal)
  187. .equatable()
  188. .frame(width: viewportWidth, height: viewport.height)
  189. .allowsHitTesting(false)
  190. selectionOverlay(viewportWidth: viewportWidth)
  191. .allowsHitTesting(false)
  192. overrideBandLabelsOverlay(viewportWidth: viewportWidth)
  193. .allowsHitTesting(false)
  194. if !interaction.followLatest {
  195. jumpToNowButton
  196. }
  197. }
  198. .frame(width: viewportWidth, height: viewport.height, alignment: .topLeading)
  199. .clipped()
  200. .contentShape(Rectangle())
  201. .simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth))
  202. .simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth))
  203. // Double-tap zooms; a single tap (only recognized once the double-tap
  204. // window lapses) selects the mark under the finger, or clears the pill.
  205. .simultaneousGesture(
  206. TapGesture(count: 2)
  207. .exclusively(before: SpatialTapGesture())
  208. .onEnded { value in
  209. switch value {
  210. case .first:
  211. cycleZoomPreset()
  212. case let .second(tap):
  213. handleTap(at: tap.location, viewportWidth: viewportWidth)
  214. }
  215. }
  216. )
  217. .onPreferenceChange(PlotFramePreferenceKey.self) { plotFrame = $0 }
  218. .onPreferenceChange(PillSizePreferenceKey.self) { pillSize = $0 }
  219. .onChange(of: interaction.scrollPosition) { _, _ in
  220. updateRenderWindow()
  221. updateFollowState()
  222. }
  223. .onChange(of: interaction.visibleSeconds) { _, _ in
  224. updateRenderWindow(force: true)
  225. }
  226. .onChange(of: model.now) { _, _ in
  227. if interaction.followLatest {
  228. scrollToNow(animated: true)
  229. } else if let pausedUntil = autoFollowPausedUntil, Date() >= pausedUntil {
  230. // Idle in history past the pause: the next data tick pulls the
  231. // chart back to live.
  232. scrollToNow(animated: true)
  233. }
  234. updateRenderWindow()
  235. }
  236. .onAppear {
  237. if !didInitialize {
  238. didInitialize = true
  239. scrollToNow(animated: false)
  240. updateRenderWindow(force: true)
  241. }
  242. }
  243. .onDisappear {
  244. momentumTask?.cancel()
  245. inspectHoldTask?.cancel()
  246. }
  247. }
  248. private var jumpToNowButton: some View {
  249. Button {
  250. scrollToNow(animated: true)
  251. } label: {
  252. Image(systemName: "arrow.right.to.line")
  253. .font(.footnote.bold())
  254. .foregroundColor(.primary)
  255. .padding(8)
  256. .background(Circle().fill(Color(.secondarySystemBackground)))
  257. .overlay(Circle().stroke(Color.primary.opacity(0.25), lineWidth: 0.5))
  258. }
  259. .padding(.trailing, 44)
  260. .padding(.bottom, 28)
  261. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
  262. }
  263. // MARK: Render window / follow state
  264. /// Re-anchors the render window when the visible window nears its edge.
  265. /// Between re-anchors, panning stays a pure offset transform.
  266. private func updateRenderWindow(force: Bool = false) {
  267. let visibleSeconds = interaction.visibleSeconds
  268. let pad = BGChartConfig.renderWindowPadFactor * visibleSeconds
  269. let margin = BGChartConfig.renderWindowMarginFactor * visibleSeconds
  270. let domainStart = model.domainStart
  271. let domainEnd = max(model.domainEnd, domainStart.addingTimeInterval(1))
  272. let visibleStart = max(interaction.scrollPosition, domainStart)
  273. // Before the first rebuild the scroll position can sit entirely outside
  274. // the (not yet populated) domain; never let the window invert.
  275. let visibleEnd = max(
  276. min(interaction.scrollPosition.addingTimeInterval(visibleSeconds), domainEnd),
  277. visibleStart
  278. )
  279. let nearLeft = visibleStart.timeIntervalSince(renderWindowStart) < margin
  280. && renderWindowStart > domainStart
  281. let nearRight = renderWindowEnd.timeIntervalSince(visibleEnd) < margin
  282. && renderWindowEnd < domainEnd
  283. let uncovered = visibleStart < renderWindowStart || visibleEnd > renderWindowEnd
  284. guard force || nearLeft || nearRight || uncovered else { return }
  285. let newStart = max(visibleStart.addingTimeInterval(-pad), domainStart)
  286. let newEnd = max(
  287. min(visibleEnd.addingTimeInterval(pad), domainEnd),
  288. newStart.addingTimeInterval(1)
  289. )
  290. guard newStart != renderWindowStart || newEnd != renderWindowEnd else { return }
  291. renderWindowStart = newStart
  292. renderWindowEnd = newEnd
  293. }
  294. /// Re-arms following only when the visible window's trailing edge has
  295. /// caught back up to "now"; otherwise the user is in history, and every
  296. /// movement there refreshes the auto-return pause.
  297. private func updateFollowState() {
  298. let trailingEdge = interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds)
  299. let follow = trailingEdge >= model.now.addingTimeInterval(-BGChartConfig.followEdgeTolerance)
  300. autoFollowPausedUntil = follow ? nil : Date().addingTimeInterval(BGChartConfig.autoFollowPause)
  301. if interaction.followLatest != follow {
  302. interaction.followLatest = follow
  303. }
  304. }
  305. /// Clamps a proposed leading edge so the visible window never leaves the domain.
  306. private func clampedLeadingEdge(_ proposed: Date) -> Date {
  307. // Before the first rebuild the domain is an epoch-0 placeholder;
  308. // clamping against it would pin the chart to "the beginning of time"
  309. // and (via the follow observer) disarm auto-following for good.
  310. guard model.domainEnd > model.domainStart.addingTimeInterval(1) else { return proposed }
  311. let earliest = model.domainStart
  312. let latest = model.domainEnd.addingTimeInterval(-interaction.visibleSeconds)
  313. return min(max(proposed, earliest), max(earliest, latest))
  314. }
  315. /// Positions "now" at `followNowFraction` of the visible window and keeps
  316. /// following armed. Never yanks the chart out from under an active gesture
  317. /// or momentum; the next data tick re-anchors as usual.
  318. private func scrollToNow(animated: Bool) {
  319. guard pinchAnchor == nil, panBaseline == nil, !isInspectLatched, momentumTask == nil else { return }
  320. interaction.followLatest = true
  321. let target = clampedLeadingEdge(
  322. model.now.addingTimeInterval(-interaction.visibleSeconds * BGChartConfig.followNowFraction)
  323. )
  324. // Animate only moves the render window already covers (its padding
  325. // guarantees a window around the visible edges): the offset transform
  326. // then interpolates over unchanged canvas content. A longer move
  327. // re-anchors the window, which would swap the content out from under
  328. // the in-flight animation — so deep returns snap instead.
  329. let distance = abs(target.timeIntervalSince(interaction.scrollPosition))
  330. if animated, distance <= interaction.visibleSeconds {
  331. withAnimation(.easeInOut(duration: 0.3)) {
  332. interaction.scrollPosition = target
  333. }
  334. } else {
  335. interaction.scrollPosition = target
  336. }
  337. }
  338. // MARK: Gestures
  339. private var isPinching: Bool { pinchAnchor != nil }
  340. /// Converts a horizontal translation (pt) into a time delta within the visible window.
  341. private func timeDelta(forTranslation dx: CGFloat, viewportWidth: CGFloat) -> TimeInterval {
  342. TimeInterval(dx / viewportWidth) * interaction.visibleSeconds
  343. }
  344. /// One-finger gesture: movement pans (with momentum on release); a press
  345. /// held in place latches into inspect, after which dragging scrubs the
  346. /// selection until the finger lifts. Panning only mutates scrollPosition
  347. /// (a transform) and scrubbing only the shell overlay, so neither path
  348. /// re-lays the canvas.
  349. private func panAndInspectGesture(viewportWidth: CGFloat) -> some Gesture {
  350. DragGesture(minimumDistance: 0)
  351. .onChanged { value in
  352. momentumTask?.cancel()
  353. momentumTask = nil
  354. guard !isPinching else {
  355. inspectHoldTask?.cancel()
  356. if selection != nil { selection = nil }
  357. panBaseline = nil
  358. touchDownTime = nil
  359. isInspectLatched = false
  360. return
  361. }
  362. lastTouchLocation = value.location
  363. if touchDownTime == nil {
  364. touchDownTime = value.time
  365. scheduleInspectHold(viewportWidth: viewportWidth)
  366. }
  367. if isInspectLatched {
  368. updateSelection(atViewportX: value.location.x, viewportWidth: viewportWidth)
  369. return
  370. }
  371. let distance = hypot(value.translation.width, value.translation.height)
  372. if panBaseline == nil, distance < BGChartConfig.inspectMovementTolerance {
  373. // Finger is stationary: the hold timer armed at touch-down
  374. // will latch inspect if it stays that way.
  375. } else {
  376. // Finger is travelling: pan. The touch can no longer become an inspect.
  377. inspectHoldTask?.cancel()
  378. if selection != nil { selection = nil }
  379. if tapped != nil { tapped = nil }
  380. if panBaseline == nil {
  381. // Compensate for the distance already travelled inside the
  382. // tolerance so the pan engages without a positional jump.
  383. panBaseline = interaction.scrollPosition.addingTimeInterval(
  384. timeDelta(forTranslation: value.translation.width, viewportWidth: viewportWidth)
  385. )
  386. }
  387. if let baseline = panBaseline {
  388. interaction.scrollPosition = clampedLeadingEdge(
  389. baseline.addingTimeInterval(
  390. -timeDelta(forTranslation: value.translation.width, viewportWidth: viewportWidth)
  391. )
  392. )
  393. }
  394. }
  395. }
  396. .onEnded { value in
  397. inspectHoldTask?.cancel()
  398. if selection != nil { selection = nil }
  399. touchDownTime = nil
  400. isInspectLatched = false
  401. lastTouchLocation = nil
  402. lastHapticAnchorDate = nil
  403. let wasPanning = panBaseline != nil
  404. panBaseline = nil
  405. guard wasPanning, !isPinching else { return }
  406. // Momentum: initial velocity in seconds of chart time per second.
  407. let velocity = -timeDelta(forTranslation: value.velocity.width, viewportWidth: viewportWidth)
  408. startMomentum(velocitySecondsPerSecond: velocity)
  409. }
  410. }
  411. /// Arms the inspect hold: after `inspectHoldDelay`, if the touch is still
  412. /// down and has neither become a pan nor a pinch, latch into inspect mode
  413. /// at the finger's last known position — with a haptic so the mode change is felt.
  414. private func scheduleInspectHold(viewportWidth: CGFloat) {
  415. inspectHoldTask?.cancel()
  416. inspectHoldTask = Task { @MainActor in
  417. try? await Task.sleep(nanoseconds: UInt64(BGChartConfig.inspectHoldDelay * 1_000_000_000))
  418. guard !Task.isCancelled,
  419. touchDownTime != nil,
  420. panBaseline == nil,
  421. !isPinching,
  422. !isInspectLatched
  423. else { return }
  424. isInspectLatched = true
  425. tapped = nil
  426. UIImpactFeedbackGenerator(style: .medium).impactOccurred()
  427. scrubHaptic.prepare()
  428. if let location = lastTouchLocation {
  429. updateSelection(atViewportX: location.x, viewportWidth: viewportWidth)
  430. }
  431. }
  432. }
  433. private func updateSelection(atViewportX x: CGFloat, viewportWidth: CGFloat) {
  434. let fraction = min(max(x / viewportWidth, 0), 1)
  435. let date = interaction.scrollPosition.addingTimeInterval(
  436. interaction.visibleSeconds * TimeInterval(fraction)
  437. )
  438. selection = date
  439. // A featherlight tick whenever the indicator snaps to a different item.
  440. let captureWindow = scrubCaptureWindow(viewportWidth: viewportWidth)
  441. if let anchor = selectionAnchor(for: date, captureWindow: captureWindow), anchor.date != lastHapticAnchorDate {
  442. lastHapticAnchorDate = anchor.date
  443. scrubHaptic.selectionChanged()
  444. scrubHaptic.prepare()
  445. }
  446. }
  447. /// Deceleration after a flick. Mutates only scrollPosition (a transform),
  448. /// so each frame costs a GPU translation — same profile as live panning.
  449. private func startMomentum(velocitySecondsPerSecond initialVelocity: TimeInterval) {
  450. let visibleSeconds = interaction.visibleSeconds
  451. // Ignore tiny flicks.
  452. guard abs(initialVelocity) > visibleSeconds * 0.05 else { return }
  453. momentumTask?.cancel()
  454. momentumTask = Task { @MainActor in
  455. var velocity = initialVelocity
  456. var expected = interaction.scrollPosition
  457. let frameDuration: TimeInterval = 1.0 / 60.0
  458. while !Task.isCancelled, abs(velocity) > visibleSeconds * 0.02 {
  459. try? await Task.sleep(nanoseconds: UInt64(frameDuration * 1_000_000_000))
  460. guard !Task.isCancelled else { break }
  461. // Someone else (small-chart tap, data snap) moved the chart: yield.
  462. guard interaction.scrollPosition == expected else { break }
  463. let next = clampedLeadingEdge(expected.addingTimeInterval(velocity * frameDuration))
  464. guard next != expected else { break } // hit a domain edge
  465. interaction.scrollPosition = next
  466. expected = next
  467. velocity *= 0.97
  468. }
  469. // Every reassignment of momentumTask cancels the old task first, so
  470. // an uncancelled task is still the current one and may clear the slot;
  471. // a cancelled one must not (it could clobber its successor).
  472. if !Task.isCancelled {
  473. momentumTask = nil
  474. }
  475. }
  476. }
  477. /// Two-finger pinch drives the zoom continuously, anchored under the pinch
  478. /// centroid. Zoom changes re-lay the canvas, so commits are quantized to a
  479. /// geometric grid to bound the number of re-layouts per pinch.
  480. private func magnifyGesture(viewportWidth _: CGFloat) -> some Gesture {
  481. MagnifyGesture(minimumScaleDelta: 0.01)
  482. .onChanged { value in
  483. momentumTask?.cancel()
  484. momentumTask = nil
  485. if pinchAnchor == nil {
  486. let fraction = min(max(value.startAnchor.x, 0), 1)
  487. pinchAnchor = (
  488. visibleAtStart: interaction.visibleSeconds,
  489. anchorDate: interaction.scrollPosition.addingTimeInterval(
  490. interaction.visibleSeconds * TimeInterval(fraction)
  491. ),
  492. anchorFraction: fraction
  493. )
  494. inspectHoldTask?.cancel()
  495. if selection != nil { selection = nil }
  496. if tapped != nil { tapped = nil }
  497. isInspectLatched = false
  498. }
  499. guard let pinch = pinchAnchor, value.magnification > 0 else { return }
  500. // Live pinch previews as a transform: stretch the already-laid-out
  501. // canvas about the centroid. Pinch out (magnification > 1) narrows
  502. // the visible window, i.e. zooms in. Once the stretch drifts past
  503. // the commit threshold, a crisp re-layout is committed mid-gesture
  504. // and the transform continues from that new baseline.
  505. let proposed = min(
  506. max(
  507. pinch.visibleAtStart / TimeInterval(value.magnification),
  508. BGChartConfig.minVisibleSeconds
  509. ),
  510. BGChartConfig.maxVisibleSeconds
  511. )
  512. pinchScale = CGFloat(interaction.visibleSeconds / proposed)
  513. let drift = BGChartConfig.pinchCommitScaleDrift
  514. if pinchScale > drift || pinchScale < 1 / drift {
  515. commitPinchZoom(proposed)
  516. }
  517. }
  518. .onEnded { _ in
  519. guard pinchAnchor != nil else { return }
  520. commitPinchZoom(interaction.visibleSeconds / TimeInterval(pinchScale))
  521. // The commit no-ops when the zoom quantizes back to the current
  522. // value; the preview must still un-stretch.
  523. pinchScale = 1
  524. pinchAnchor = nil
  525. interaction.persistZoom()
  526. }
  527. }
  528. /// Quantizes to the geometric zoom grid and re-lays the canvas exactly
  529. /// once: window re-anchor happens in the same transaction.
  530. private func commitPinchZoom(_ proposed: TimeInterval) {
  531. guard let pinch = pinchAnchor else { return }
  532. let ratio = BGChartConfig.zoomStepRatio
  533. let base = BGChartConfig.zoomGridBaseSeconds
  534. let step = (log(proposed / base) / log(ratio)).rounded()
  535. var quantized = base * pow(ratio, step)
  536. quantized = min(
  537. max(quantized, BGChartConfig.minVisibleSeconds),
  538. BGChartConfig.maxVisibleSeconds
  539. )
  540. guard quantized != interaction.visibleSeconds else { return }
  541. interaction.visibleSeconds = quantized
  542. interaction.scrollPosition = clampedLeadingEdge(
  543. pinch.anchorDate.addingTimeInterval(-quantized * TimeInterval(pinch.anchorFraction))
  544. )
  545. updateRenderWindow(force: true)
  546. pinchScale = 1
  547. }
  548. /// Anchor for the live-pinch stretch: the centroid's layout position on
  549. /// the canvas, so the content under the fingers stays put on screen.
  550. private func pinchScaleAnchor(viewportWidth: CGFloat, canvasWidth: CGFloat) -> UnitPoint {
  551. guard let pinch = pinchAnchor, canvasWidth > 0 else { return .center }
  552. return UnitPoint(x: pinch.anchorFraction * viewportWidth / canvasWidth, y: 0.5)
  553. }
  554. /// Double-tap cycles the zoom presets. Snap, no animation: animating the
  555. /// zoom would animate canvasWidth, re-laying the canvas every frame.
  556. private func cycleZoomPreset() {
  557. let presets = BGChartConfig.zoomPresets
  558. let current = interaction.visibleSeconds
  559. let next = presets.first(where: { $0 > current + 1 }) ?? presets[0]
  560. momentumTask?.cancel()
  561. momentumTask = nil
  562. tapped = nil
  563. let trailing = interaction.scrollPosition.addingTimeInterval(current)
  564. interaction.visibleSeconds = next
  565. if interaction.followLatest {
  566. interaction.scrollPosition = clampedLeadingEdge(
  567. model.now.addingTimeInterval(-next * BGChartConfig.followNowFraction)
  568. )
  569. } else {
  570. interaction.scrollPosition = clampedLeadingEdge(trailing.addingTimeInterval(-next))
  571. }
  572. updateRenderWindow(force: true)
  573. interaction.persistZoom()
  574. }
  575. // MARK: Selection
  576. private struct SelectionAnchor {
  577. /// Where the pill/indicator is drawn — a treatment's `drawnDate`, so the
  578. /// indicator lands on the symbol even when decluttering moved it.
  579. let date: Date
  580. let value: Double
  581. /// One pill entry per item under the selector (see PillLabel).
  582. let texts: [String]
  583. }
  584. /// Feeds every treatment mark to `body` as (drawnDate, value, pillText).
  585. /// Single source for both the scrub lookup and the tap hit test.
  586. private func forEachTreatmentAnchor(_ body: (Date, Double, String) -> Void) {
  587. for group in [model.boluses, model.carbs, model.smbs, model.bgChecks,
  588. model.notes, model.suspends, model.resumes, model.sensorStarts]
  589. {
  590. for t in group {
  591. body(t.drawnDate, t.sgv, t.pillText)
  592. }
  593. }
  594. }
  595. /// Pill entry for a BG reading. Shared by the scrub lookup and the tap hit test.
  596. private func bgPillText(for point: BGChartModel.BGPoint) -> String {
  597. "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))"
  598. }
  599. private func bandPillTexts(at date: Date) -> [String] {
  600. var texts: [String] = []
  601. if let band = model.overrides
  602. .filter({ date >= $0.start && date <= $0.end })
  603. .max(by: { $0.start < $1.start })
  604. {
  605. texts.append(band.pillText)
  606. }
  607. if let band = model.tempTargets
  608. .filter({ date >= $0.start && date <= $0.end })
  609. .max(by: { $0.start < $1.start })
  610. {
  611. texts.append(band.pillText)
  612. }
  613. return texts
  614. }
  615. /// Band (override / temp target) under the given date+value, if any.
  616. private func bandAnchor(at date: Date, value: Double) -> SelectionAnchor? {
  617. for band in model.overrides where date >= band.start && date <= band.end {
  618. if value >= band.yBottom, value <= band.yTop {
  619. let midY = (band.yTop + band.yBottom) / 2
  620. return SelectionAnchor(date: date, value: midY, texts: [band.pillText])
  621. }
  622. }
  623. for band in model.tempTargets where date >= band.start && date <= band.end {
  624. if value >= band.yBottom, value <= band.yTop {
  625. let midY = (band.yTop + band.yBottom) / 2
  626. return SelectionAnchor(date: date, value: midY, texts: [band.pillText])
  627. }
  628. }
  629. return nil
  630. }
  631. /// Seconds of chart time covered by `scrubCaptureRadius` at the current
  632. /// zoom, bounded by `scrubCaptureMaxSeconds`.
  633. private func scrubCaptureWindow(viewportWidth: CGFloat) -> TimeInterval {
  634. min(
  635. BGChartConfig.scrubCaptureMaxSeconds,
  636. TimeInterval(BGChartConfig.scrubCaptureRadius / viewportWidth) * interaction.visibleSeconds
  637. )
  638. }
  639. /// Scrub lookup (time-only). Collects everything under the finger instead
  640. /// of picking a single winner: every treatment inside the capture window
  641. /// joins the pill, and the nearest BG reading always does — so treatments
  642. /// and glucose readings can never hide one another. The indicator snaps
  643. /// to the nearest collected item; the pill stacks them all (treatments in
  644. /// drawn order, BG last).
  645. private func selectionAnchor(for selected: Date, captureWindow: TimeInterval) -> SelectionAnchor? {
  646. struct Item {
  647. let date: Date
  648. let value: Double
  649. let text: String
  650. let distance: TimeInterval
  651. }
  652. var captured: [Item] = []
  653. var nearestTreatment: Item?
  654. forEachTreatmentAnchor { date, value, text in
  655. let item = Item(date: date, value: value, text: text, distance: abs(date.timeIntervalSince(selected)))
  656. if item.distance <= captureWindow {
  657. captured.append(item)
  658. }
  659. if item.distance < (nearestTreatment?.distance ?? .greatestFiniteMagnitude) {
  660. nearestTreatment = item
  661. }
  662. }
  663. captured.sort { $0.date < $1.date }
  664. var nearestBG: Item?
  665. for p in model.bg {
  666. let d = abs(p.date.timeIntervalSince(selected))
  667. if d < (nearestBG?.distance ?? .greatestFiniteMagnitude) {
  668. nearestBG = Item(date: p.date, value: p.value, text: bgPillText(for: p), distance: d)
  669. }
  670. }
  671. var items = captured
  672. if let nearestBG, nearestBG.distance <= BGChartConfig.selectionTolerance {
  673. items.append(nearestBG)
  674. }
  675. if let primary = items.min(by: { $0.distance < $1.distance }) {
  676. let texts = items.map(\.text) + bandPillTexts(at: selected)
  677. return SelectionAnchor(date: primary.date, value: primary.value, texts: texts)
  678. }
  679. // Nothing under the finger. Reach for the nearest treatment (data gaps
  680. // leave treatments without BG neighbors), then for a band (any height)
  681. // at the scrub time.
  682. if let nearestTreatment, nearestTreatment.distance <= BGChartConfig.selectionTolerance {
  683. let texts = [nearestTreatment.text] + bandPillTexts(at: selected)
  684. return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: texts)
  685. }
  686. for band in model.overrides where selected >= band.start && selected <= band.end {
  687. let midY = (band.yTop + band.yBottom) / 2
  688. return SelectionAnchor(date: selected, value: midY, texts: [band.pillText])
  689. }
  690. for band in model.tempTargets where selected >= band.start && selected <= band.end {
  691. let midY = (band.yTop + band.yBottom) / 2
  692. return SelectionAnchor(date: selected, value: midY, texts: [band.pillText])
  693. }
  694. return nil
  695. }
  696. /// Tap hit test (screen-space, 2D). Treatments take priority, then BG
  697. /// points, then the override/temp-target bands under the finger.
  698. /// Returns nil when the tap lands on nothing — which clears the pill.
  699. private func tappedAnchor(at location: CGPoint, viewportWidth: CGFloat) -> SelectionAnchor? {
  700. let radius = BGChartConfig.tapHitRadius
  701. var best: SelectionAnchor?
  702. var bestDistance2 = radius * radius
  703. func consider(_ date: Date, _ value: Double, _ text: String) {
  704. let dx = xPosition(for: date, viewportWidth: viewportWidth) - location.x
  705. let dy = yPosition(forValue: value) - location.y
  706. let d2 = dx * dx + dy * dy
  707. if d2 <= bestDistance2 {
  708. bestDistance2 = d2
  709. best = SelectionAnchor(date: date, value: value, texts: [text])
  710. }
  711. }
  712. forEachTreatmentAnchor(consider)
  713. if best == nil {
  714. for p in model.bg {
  715. consider(p.date, p.value, bgPillText(for: p))
  716. }
  717. }
  718. if best == nil {
  719. let date = interaction.scrollPosition.addingTimeInterval(
  720. interaction.visibleSeconds * TimeInterval(location.x / viewportWidth)
  721. )
  722. return bandAnchor(at: date, value: value(atY: location.y))
  723. }
  724. if let best {
  725. let texts = best.texts + bandPillTexts(at: best.date)
  726. return SelectionAnchor(date: best.date, value: best.value, texts: texts)
  727. }
  728. return nil
  729. }
  730. private func handleTap(at location: CGPoint, viewportWidth: CGFloat) {
  731. guard plotFrame.height > 0 else { return }
  732. tapped = tappedAnchor(at: location, viewportWidth: viewportWidth)
  733. }
  734. /// The anchor the overlay should show: a live scrub wins over a sticky tap.
  735. private func activeAnchor(viewportWidth: CGFloat) -> SelectionAnchor? {
  736. if isInspectLatched, let selected = selection {
  737. return selectionAnchor(for: selected, captureWindow: scrubCaptureWindow(viewportWidth: viewportWidth))
  738. }
  739. return tapped
  740. }
  741. // MARK: Value-to-pixel maps (shared by the selection overlay and tap hit test)
  742. private func xPosition(for date: Date, viewportWidth: CGFloat) -> CGFloat {
  743. CGFloat(
  744. date.timeIntervalSince(interaction.scrollPosition) / interaction.visibleSeconds
  745. ) * viewportWidth
  746. }
  747. private func yPosition(forValue value: Double) -> CGFloat {
  748. let yMax = chartYDomainUpperBound(model.maxBG)
  749. let clamped = min(max(value, 0), yMax)
  750. return plotFrame.minY + CGFloat(1 - clamped / yMax) * plotFrame.height
  751. }
  752. private func value(atY y: CGFloat) -> Double {
  753. guard plotFrame.height > 0 else { return 0 }
  754. let yMax = chartYDomainUpperBound(model.maxBG)
  755. let fraction = 1 - (y - plotFrame.minY) / plotFrame.height
  756. return Double(min(max(fraction, 0), 1)) * yMax
  757. }
  758. @ViewBuilder
  759. private func overrideBandLabelsOverlay(viewportWidth: CGFloat) -> some View {
  760. if plotFrame.height > 0 {
  761. let visibleStart = interaction.scrollPosition
  762. let visibleEnd = interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds)
  763. ForEach(model.overrides.filter { $0.end >= visibleStart && $0.start <= visibleEnd }) { band in
  764. let visibleBandStart = max(band.start, visibleStart)
  765. let visibleBandEnd = min(band.end, visibleEnd)
  766. let xStart = xPosition(for: visibleBandStart, viewportWidth: viewportWidth)
  767. let xEnd = xPosition(for: visibleBandEnd, viewportWidth: viewportWidth)
  768. let bandWidth = max(0, xEnd - xStart)
  769. let y = yPosition(forValue: (band.yBottom + band.yTop) / 2)
  770. if bandWidth > 24 {
  771. Text(band.label)
  772. .font(.caption2)
  773. .foregroundStyle(.white)
  774. .lineLimit(1)
  775. .truncationMode(.tail)
  776. .padding(.trailing, 10)
  777. .frame(width: max(0, bandWidth - 6), alignment: .trailing)
  778. .clipped()
  779. .position(x: xStart + bandWidth / 2, y: y)
  780. }
  781. }
  782. }
  783. }
  784. /// Vertical indicator + pill for the current selection, rendered in the
  785. /// shell with the same linear maps the canvas uses — neither scrubbing
  786. /// nor a tapped pill ever re-lays the canvas.
  787. ///
  788. /// The pill wraps long texts (notes) at its max width; placement uses the
  789. /// measured pill size so the pill always sits fully on screen, below the
  790. /// anchor when there is room and above it otherwise. Wrapping relies on
  791. /// SwiftUI's word wrapping, which respects the actual font metrics, so
  792. /// there is no manual line splitting.
  793. @ViewBuilder
  794. private func selectionOverlay(viewportWidth: CGFloat) -> some View {
  795. if plotFrame.height > 0, let anchor = activeAnchor(viewportWidth: viewportWidth) {
  796. let x = xPosition(for: anchor.date, viewportWidth: viewportWidth)
  797. if x >= 0, x <= viewportWidth {
  798. let y = yPosition(forValue: anchor.value)
  799. Rectangle()
  800. .fill(Color.primary.opacity(0.5))
  801. .frame(width: 1, height: plotFrame.height)
  802. .position(x: x, y: plotFrame.midY)
  803. // Measured size lags the text by one frame; fall back to a
  804. // small nominal size until the first measurement lands.
  805. let pillW = max(pillSize.width, 60)
  806. let pillH = max(pillSize.height, 28)
  807. let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4)
  808. let below = y + 14 + pillH / 2
  809. let above = y - 14 - pillH / 2
  810. let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4
  811. let labelY = fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4)
  812. PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16))
  813. .position(x: labelX, y: labelY)
  814. }
  815. }
  816. }
  817. }
  818. // MARK: - Small chart (overview + tap-to-navigate)
  819. private struct SmallBGChart: View {
  820. @ObservedObject var model: BGChartModel
  821. @ObservedObject var interaction: BGChartInteraction
  822. var body: some View {
  823. GeometryReader { geo in
  824. let width = max(geo.size.width, 1)
  825. let domainSeconds = max(model.domainEnd.timeIntervalSince(model.domainStart), 1)
  826. ZStack(alignment: .topLeading) {
  827. BGChartCanvas(
  828. model: model,
  829. generation: model.generation,
  830. isSmall: true,
  831. windowStart: model.domainStart,
  832. windowEnd: model.domainStart.addingTimeInterval(domainSeconds),
  833. canvasWidth: width,
  834. height: geo.size.height,
  835. visibleSeconds: domainSeconds,
  836. timeZone: .current
  837. )
  838. .equatable()
  839. viewportBox(width: width, height: geo.size.height, domainSeconds: domainSeconds)
  840. .allowsHitTesting(false)
  841. }
  842. .contentShape(Rectangle())
  843. .gesture(
  844. DragGesture(minimumDistance: 0)
  845. .onEnded { value in
  846. navigate(toLocalX: value.location.x, width: width, domainSeconds: domainSeconds)
  847. }
  848. )
  849. }
  850. .background(Color(.systemBackground))
  851. }
  852. /// Box showing where the main chart's visible window currently sits.
  853. private func viewportBox(width: CGFloat, height: CGFloat, domainSeconds: TimeInterval) -> some View {
  854. let startFraction = interaction.scrollPosition.timeIntervalSince(model.domainStart) / domainSeconds
  855. let boxWidth = min(max(2, CGFloat(interaction.visibleSeconds / domainSeconds) * width), width)
  856. let boxLeft = min(max(CGFloat(startFraction) * width, 0), width - boxWidth)
  857. return Rectangle()
  858. .fill(Color.primary.opacity(0.08))
  859. .overlay(Rectangle().stroke(Color.primary.opacity(0.35), lineWidth: 1))
  860. .frame(width: boxWidth, height: height)
  861. .offset(x: boxLeft)
  862. }
  863. /// Maps a tap x to a time and recenters the main chart on it (clamped to
  864. /// the data window). Following re-arms automatically via the main chart's
  865. /// scroll observer if the user lands back at the latest data.
  866. private func navigate(toLocalX x: CGFloat, width: CGFloat, domainSeconds: TimeInterval) {
  867. let fraction = min(max(x / width, 0), 1)
  868. let date = model.domainStart.addingTimeInterval(domainSeconds * TimeInterval(fraction))
  869. let length = interaction.visibleSeconds
  870. var target = date.addingTimeInterval(-length / 2)
  871. let minStart = model.domainStart
  872. let maxStart = model.domainEnd.addingTimeInterval(-length)
  873. if maxStart > minStart {
  874. target = min(max(target, minStart), maxStart)
  875. }
  876. // Same rule as the main chart's scrollToNow: only nearby targets (still
  877. // inside the render window) animate cleanly; long jumps snap.
  878. if abs(target.timeIntervalSince(interaction.scrollPosition)) <= interaction.visibleSeconds {
  879. withAnimation(.easeInOut(duration: 0.3)) {
  880. interaction.scrollPosition = target
  881. }
  882. } else {
  883. interaction.scrollPosition = target
  884. }
  885. }
  886. }
  887. // MARK: - Canvas (laid out once per data / zoom change; translated while panning)
  888. /// The chart content, laid out over the render window at fixed width. Stored
  889. /// properties deliberately exclude the pan position, and the explicit
  890. /// Equatable conformance below compares only cheap metadata (`generation`
  891. /// stands in for the data arrays) — so SwiftUI provably skips this body
  892. /// during panning and momentum.
  893. private struct BGChartCanvas: View, Equatable {
  894. let model: BGChartModel // plain reference on purpose: updates arrive via `generation`
  895. let generation: Int
  896. let isSmall: Bool
  897. let windowStart: Date
  898. let windowEnd: Date
  899. let canvasWidth: CGFloat
  900. let height: CGFloat
  901. let visibleSeconds: TimeInterval
  902. let timeZone: TimeZone
  903. static func == (lhs: BGChartCanvas, rhs: BGChartCanvas) -> Bool {
  904. lhs.generation == rhs.generation &&
  905. lhs.isSmall == rhs.isSmall &&
  906. lhs.windowStart == rhs.windowStart &&
  907. lhs.windowEnd == rhs.windowEnd &&
  908. lhs.canvasWidth == rhs.canvasWidth &&
  909. lhs.height == rhs.height &&
  910. lhs.visibleSeconds == rhs.visibleSeconds &&
  911. lhs.timeZone == rhs.timeZone
  912. }
  913. private var basalScale: Double {
  914. guard model.maxBasal > 0 else { return 0 }
  915. return model.maxBG / model.maxBasal
  916. }
  917. var body: some View {
  918. let chart = Chart {
  919. bgBandMarks
  920. basalMarks
  921. scheduledBasalMarks
  922. coneMarks
  923. yesterdayMarks
  924. bgLineMarks
  925. bgPointsMark
  926. predictionLineMark
  927. if !isSmall {
  928. predictionVariantMarks
  929. }
  930. treatmentMarks
  931. if !isSmall {
  932. ruleMarks
  933. }
  934. }
  935. .chartXScale(domain: windowStart ... windowEnd)
  936. .chartYScale(domain: 0 ... chartYDomainUpperBound(model.maxBG))
  937. .chartLegend(.hidden)
  938. .chartYAxis(.hidden)
  939. return Group {
  940. if isSmall {
  941. chart.chartXAxis(.hidden)
  942. } else {
  943. chart.chartXAxis {
  944. AxisMarks(values: xAxisMarkDates()) { value in
  945. AxisValueLabel {
  946. if let date = value.as(Date.self) {
  947. xAxisLabel(for: date)
  948. }
  949. }
  950. }
  951. }
  952. }
  953. }
  954. .frame(width: canvasWidth, height: height)
  955. }
  956. // MARK: Windowing
  957. // The mark series sliced to the render window: marks outside it clip
  958. // invisibly but still cost layout, so an unfiltered re-layout does several
  959. // times the work for nothing.
  960. private func windowed<T>(_ items: [T], date: (T) -> Date) -> [T] {
  961. items.filter { item in
  962. let d = date(item)
  963. return d >= windowStart && d <= windowEnd
  964. }
  965. }
  966. /// For line/area series: like `windowed`, but keeps one point beyond each
  967. /// edge so segments crossing (or spanning) the window survive. Assumes
  968. /// ascending dates.
  969. private func windowedLine<T>(_ items: [T], date: (T) -> Date) -> [T] {
  970. guard items.count > 1 else { return items }
  971. let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count
  972. let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1
  973. let lo = max(firstInside - 1, 0)
  974. let hi = min(firstBeyond, items.count - 1)
  975. guard lo <= hi else { return [] }
  976. return Array(items[lo ... hi])
  977. }
  978. // MARK: X axis
  979. /// Grid/label stride ladder for the current zoom level.
  980. private var xAxisStrideSeconds: TimeInterval {
  981. let visibleHours = visibleSeconds / 3600
  982. if visibleHours <= 2 { return 1800 }
  983. if visibleHours <= 6 { return 3600 }
  984. if visibleHours <= 12 { return 2 * 3600 }
  985. return 4 * 3600
  986. }
  987. /// Axis mark dates anchored to local midnight (in the configured graph
  988. /// time zone), unlike `.stride(by: .hour)`, which anchors to the domain start.
  989. private func xAxisMarkDates() -> [Date] {
  990. var cal = Calendar(identifier: .gregorian)
  991. cal.timeZone = timeZone
  992. let stride = xAxisStrideSeconds
  993. var mark = cal.startOfDay(for: windowStart)
  994. var marks: [Date] = []
  995. while mark <= windowEnd {
  996. if mark >= windowStart {
  997. marks.append(mark)
  998. }
  999. mark = mark.addingTimeInterval(stride)
  1000. }
  1001. return marks
  1002. }
  1003. /// Midnight ticks carry the day ("Tue 7") so panned-back history stays
  1004. /// unambiguous; other ticks show the hour (plus minutes at sub-hour strides).
  1005. private func xAxisLabel(for date: Date) -> some View {
  1006. var cal = Calendar(identifier: .gregorian)
  1007. cal.timeZone = timeZone
  1008. let comps = cal.dateComponents([.hour, .minute], from: date)
  1009. return Group {
  1010. if comps.hour == 0, comps.minute == 0 {
  1011. Text(date, format: .dateTime.weekday(.abbreviated).day())
  1012. } else if xAxisStrideSeconds < 3600 {
  1013. Text(date, format: .dateTime.hour().minute())
  1014. } else {
  1015. Text(date, format: .dateTime.hour())
  1016. }
  1017. }
  1018. .font(.footnote)
  1019. .environment(\.timeZone, timeZone)
  1020. }
  1021. // MARK: Marks
  1022. @ChartContentBuilder
  1023. private var bgBandMarks: some ChartContent {
  1024. ForEach(model.overrides.filter { $0.end >= windowStart && $0.start <= windowEnd }) { band in
  1025. RectangleMark(
  1026. xStart: .value("start", band.start),
  1027. xEnd: .value("end", band.end),
  1028. yStart: .value("yBottom", band.yBottom),
  1029. yEnd: .value("yTop", band.yTop)
  1030. )
  1031. .foregroundStyle(model.overrideColor.opacity(0.6))
  1032. }
  1033. ForEach(model.tempTargets.filter { $0.end >= windowStart && $0.start <= windowEnd }) { band in
  1034. RectangleMark(
  1035. xStart: .value("start", band.start),
  1036. xEnd: .value("end", band.end),
  1037. yStart: .value("yBottom", band.yBottom),
  1038. yEnd: .value("yTop", band.yTop)
  1039. )
  1040. .foregroundStyle(model.tempTargetColor.opacity(0.5))
  1041. }
  1042. }
  1043. @ChartContentBuilder
  1044. private var basalMarks: some ChartContent {
  1045. ForEach(model.basal.filter { $0.end >= windowStart && $0.start <= windowEnd }) { step in
  1046. RectangleMark(
  1047. xStart: .value("start", step.start),
  1048. xEnd: .value("end", step.end),
  1049. yStart: .value("yBottom", 0.0),
  1050. yEnd: .value("rate", step.rate * basalScale)
  1051. )
  1052. .foregroundStyle(.blue.opacity(0.35))
  1053. }
  1054. }
  1055. @ChartContentBuilder
  1056. private var scheduledBasalMarks: some ChartContent {
  1057. ForEach(windowedLine(model.basalScheduled) { $0.date }) { pt in
  1058. LineMark(
  1059. x: .value("time", pt.date),
  1060. y: .value("rate", pt.rate * basalScale),
  1061. series: .value("series", "basalScheduled")
  1062. )
  1063. .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
  1064. .foregroundStyle(Color.blue.opacity(0.8))
  1065. }
  1066. }
  1067. @ChartContentBuilder
  1068. private var coneMarks: some ChartContent {
  1069. ForEach(windowedLine(model.cone) { $0.date }) { pt in
  1070. AreaMark(
  1071. x: .value("time", pt.date),
  1072. yStart: .value("yMin", pt.yMin),
  1073. yEnd: .value("yMax", pt.yMax)
  1074. )
  1075. // Same fill as Trio's cone.
  1076. .foregroundStyle(Color(.systemBlue).opacity(0.4))
  1077. .interpolationMethod(.monotone)
  1078. }
  1079. }
  1080. @ChartContentBuilder
  1081. private var yesterdayMarks: some ChartContent {
  1082. ForEach(windowedLine(model.yesterday) { $0.date }) { pt in
  1083. LineMark(
  1084. x: .value("time", pt.date),
  1085. y: .value("bg", pt.value),
  1086. series: .value("series", "yesterday")
  1087. )
  1088. .foregroundStyle(Color(.systemGray).opacity(0.4))
  1089. .lineStyle(StrokeStyle(lineWidth: 1.5))
  1090. .interpolationMethod(.linear)
  1091. }
  1092. }
  1093. @ChartContentBuilder
  1094. private var bgLineMarks: some ChartContent {
  1095. if model.showLines {
  1096. ForEach(model.bgRuns) { run in
  1097. if let first = run.points.first, let last = run.points.last,
  1098. last.date >= windowStart, first.date <= windowEnd
  1099. {
  1100. ForEach(windowedLine(run.points) { $0.date }) { pt in
  1101. LineMark(
  1102. x: .value("time", pt.date),
  1103. y: .value("bg", pt.value),
  1104. series: .value("series", "bg-\(run.id)")
  1105. )
  1106. .foregroundStyle(run.color)
  1107. .lineStyle(StrokeStyle(lineWidth: 2.5))
  1108. .interpolationMethod(.linear)
  1109. }
  1110. }
  1111. }
  1112. }
  1113. }
  1114. @ChartContentBuilder
  1115. private var bgPointsMark: some ChartContent {
  1116. if model.showDots {
  1117. ForEach(windowed(model.bg) { $0.date }) { pt in
  1118. PointMark(
  1119. x: .value("time", pt.date),
  1120. y: .value("bg", pt.value)
  1121. )
  1122. .symbolSize(isSmall ? 14 : 30)
  1123. .foregroundStyle(pt.color)
  1124. }
  1125. }
  1126. }
  1127. @ChartContentBuilder
  1128. private var predictionLineMark: some ChartContent {
  1129. ForEach(windowedLine(model.prediction) { $0.date }) { pt in
  1130. LineMark(
  1131. x: .value("time", pt.date),
  1132. y: .value("bg", pt.value),
  1133. series: .value("series", "prediction")
  1134. )
  1135. .foregroundStyle(.purple)
  1136. .lineStyle(StrokeStyle(lineWidth: 2))
  1137. }
  1138. }
  1139. @ChartContentBuilder
  1140. private var predictionVariantMarks: some ChartContent {
  1141. ForEach(windowedLine(model.ztPrediction) { $0.date }) { pt in
  1142. LineMark(
  1143. x: .value("time", pt.date),
  1144. y: .value("bg", pt.value),
  1145. series: .value("series", "zt")
  1146. )
  1147. .foregroundStyle(Color(.systemGray))
  1148. .lineStyle(StrokeStyle(lineWidth: 2))
  1149. }
  1150. ForEach(windowedLine(model.iobPrediction) { $0.date }) { pt in
  1151. LineMark(
  1152. x: .value("time", pt.date),
  1153. y: .value("bg", pt.value),
  1154. series: .value("series", "iob")
  1155. )
  1156. .foregroundStyle(Color(.systemBlue))
  1157. .lineStyle(StrokeStyle(lineWidth: 2))
  1158. }
  1159. ForEach(windowedLine(model.cobPrediction) { $0.date }) { pt in
  1160. LineMark(
  1161. x: .value("time", pt.date),
  1162. y: .value("bg", pt.value),
  1163. series: .value("series", "cob")
  1164. )
  1165. .foregroundStyle(Color(.systemOrange))
  1166. .lineStyle(StrokeStyle(lineWidth: 2))
  1167. }
  1168. ForEach(windowedLine(model.uamPrediction) { $0.date }) { pt in
  1169. LineMark(
  1170. x: .value("time", pt.date),
  1171. y: .value("bg", pt.value),
  1172. series: .value("series", "uam")
  1173. )
  1174. .foregroundStyle(Color(.systemPink))
  1175. .lineStyle(StrokeStyle(lineWidth: 2))
  1176. }
  1177. }
  1178. @ChartContentBuilder
  1179. private var treatmentMarks: some ChartContent {
  1180. ForEach(windowed(model.boluses) { $0.drawnDate }) { pt in
  1181. PointMark(
  1182. x: .value("time", pt.drawnDate),
  1183. y: .value("sgv", pt.sgv)
  1184. )
  1185. .symbolSize(isSmall ? 24 : 64)
  1186. .foregroundStyle(Color.blue.opacity(0.75))
  1187. .annotation(position: .top, alignment: .center) {
  1188. if !isSmall, Storage.shared.showValues.value {
  1189. Text(pt.label).font(.caption2).foregroundColor(.primary)
  1190. }
  1191. }
  1192. }
  1193. ForEach(windowed(model.carbs) { $0.drawnDate }) { pt in
  1194. PointMark(
  1195. x: .value("time", pt.drawnDate),
  1196. y: .value("sgv", pt.sgv)
  1197. )
  1198. .symbolSize(isSmall ? 24 : 64)
  1199. .foregroundStyle(Color.orange.opacity(0.75))
  1200. .annotation(position: .top, alignment: .center) {
  1201. if !isSmall, Storage.shared.showValues.value {
  1202. Text(pt.label).font(.caption2).foregroundColor(.primary)
  1203. }
  1204. }
  1205. }
  1206. ForEach(windowed(model.smbs) { $0.drawnDate }) { pt in
  1207. PointMark(
  1208. x: .value("time", pt.drawnDate),
  1209. y: .value("sgv", pt.sgv)
  1210. )
  1211. .symbol(DownwardTriangle())
  1212. .symbolSize(isSmall ? 28 : 64)
  1213. .foregroundStyle(Color.blue.opacity(0.75))
  1214. .annotation(position: .top, alignment: .center) {
  1215. if !isSmall, Storage.shared.showValues.value {
  1216. Text(pt.label).font(.caption2).foregroundColor(.primary)
  1217. }
  1218. }
  1219. }
  1220. ForEach(windowed(model.bgChecks) { $0.drawnDate }) { pt in
  1221. PointMark(
  1222. x: .value("time", pt.drawnDate),
  1223. y: .value("sgv", pt.sgv)
  1224. )
  1225. .symbolSize(isSmall ? 22 : 54)
  1226. .foregroundStyle(Color.red.opacity(0.75))
  1227. }
  1228. ForEach(windowed(model.suspends) { $0.drawnDate }) { pt in
  1229. PointMark(
  1230. x: .value("time", pt.drawnDate),
  1231. y: .value("sgv", pt.sgv)
  1232. )
  1233. .symbol(.square)
  1234. .symbolSize(isSmall ? 22 : 54)
  1235. .foregroundStyle(Color.teal.opacity(0.75))
  1236. }
  1237. ForEach(windowed(model.resumes) { $0.drawnDate }) { pt in
  1238. PointMark(
  1239. x: .value("time", pt.drawnDate),
  1240. y: .value("sgv", pt.sgv)
  1241. )
  1242. .symbol(.square)
  1243. .symbolSize(isSmall ? 22 : 54)
  1244. .foregroundStyle(Color.teal.opacity(0.5))
  1245. }
  1246. ForEach(windowed(model.sensorStarts) { $0.drawnDate }) { pt in
  1247. PointMark(
  1248. x: .value("time", pt.drawnDate),
  1249. y: .value("sgv", pt.sgv)
  1250. )
  1251. .symbol(.cross)
  1252. .symbolSize(isSmall ? 22 : 54)
  1253. .foregroundStyle(Color.indigo.opacity(0.75))
  1254. }
  1255. ForEach(windowed(model.notes) { $0.drawnDate }) { pt in
  1256. PointMark(
  1257. x: .value("time", pt.drawnDate),
  1258. y: .value("sgv", pt.sgv)
  1259. )
  1260. .symbolSize(isSmall ? 22 : 54)
  1261. .foregroundStyle(Color.gray.opacity(0.75))
  1262. }
  1263. }
  1264. @ChartContentBuilder
  1265. private var ruleMarks: some ChartContent {
  1266. RuleMark(y: .value("low", model.lowLine))
  1267. .lineStyle(StrokeStyle(lineWidth: 2))
  1268. .foregroundStyle(Color.red.opacity(0.5))
  1269. RuleMark(y: .value("high", model.highLine))
  1270. .lineStyle(StrokeStyle(lineWidth: 2))
  1271. .foregroundStyle(Color.yellow.opacity(0.5))
  1272. if model.now >= windowStart, model.now <= windowEnd {
  1273. RuleMark(x: .value("now", model.now))
  1274. .lineStyle(StrokeStyle(lineWidth: 1))
  1275. .foregroundStyle(Color.gray.opacity(0.6))
  1276. }
  1277. if model.show30Min, let d = model.thirtyMinMark, d >= windowStart, d <= windowEnd {
  1278. RuleMark(x: .value("30m", d))
  1279. .lineStyle(StrokeStyle(lineWidth: 0.5))
  1280. .foregroundStyle(Color.blue.opacity(0.7))
  1281. }
  1282. if model.show90Min, let d = model.ninetyMinMark, d >= windowStart, d <= windowEnd {
  1283. RuleMark(x: .value("90m", d))
  1284. .lineStyle(StrokeStyle(lineWidth: 0.5))
  1285. .foregroundStyle(Color.orange.opacity(0.7))
  1286. }
  1287. if model.showDIA {
  1288. ForEach(model.diaMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in
  1289. RuleMark(x: .value("dia", d))
  1290. .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2]))
  1291. .foregroundStyle(Color.gray.opacity(0.5))
  1292. }
  1293. }
  1294. if model.showMidnight {
  1295. ForEach(model.midnightMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in
  1296. RuleMark(x: .value("midnight", d))
  1297. .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [5, 3]))
  1298. .foregroundStyle(Color.teal.opacity(0.6))
  1299. }
  1300. }
  1301. }
  1302. }
  1303. // MARK: - Pinned y-axis overlay
  1304. /// Renders only the y-axes — BG values trailing, basal rates leading — at a
  1305. /// fixed position over the scrolling canvas, plus a phantom x-axis label that
  1306. /// reserves exactly the height of the canvas's hour labels so both plots end
  1307. /// at the same y. Also reports its plot frame so the shell's selection
  1308. /// overlay shares the same value-to-pixel mapping.
  1309. private struct StaticYAxisOverlay: View, Equatable {
  1310. let generation: Int
  1311. let maxBG: Double
  1312. let maxBasal: Double
  1313. private var basalScale: Double {
  1314. guard maxBasal > 0 else { return 0 }
  1315. return maxBG / maxBasal
  1316. }
  1317. private var leadingBasalTicks: [Double] {
  1318. guard basalScale > 0 else { return [] }
  1319. let ticks = stride(from: 0.0, through: maxBasal, by: max(0.5, maxBasal / 4))
  1320. .map { $0 * basalScale }
  1321. return Array(ticks)
  1322. }
  1323. var body: some View {
  1324. Chart {
  1325. // Invisible content at the domain corners so the scales resolve
  1326. // and the plot (and with it the axes) materializes.
  1327. PointMark(x: .value("edge", 0.0), y: .value("min", 0.0))
  1328. .opacity(0)
  1329. PointMark(x: .value("edge", 1.0), y: .value("max", maxBG))
  1330. .opacity(0)
  1331. }
  1332. .chartXScale(domain: 0 ... 1)
  1333. .chartYScale(domain: 0 ... chartYDomainUpperBound(maxBG))
  1334. .chartLegend(.hidden)
  1335. .chartXAxis {
  1336. AxisMarks(values: [0.5]) { _ in
  1337. AxisValueLabel {
  1338. Text("00").font(.footnote).foregroundStyle(.clear)
  1339. }
  1340. }
  1341. }
  1342. .chartYAxis {
  1343. AxisMarks(position: .trailing) { value in
  1344. AxisTick()
  1345. AxisValueLabel {
  1346. if let v = value.as(Double.self) {
  1347. Text(Localizer.toDisplayUnits(String(v)))
  1348. }
  1349. }
  1350. }
  1351. AxisMarks(position: .leading, values: leadingBasalTicks) { value in
  1352. AxisTick()
  1353. AxisValueLabel {
  1354. if let v = value.as(Double.self) {
  1355. let rate = basalScale > 0 ? v / basalScale : 0
  1356. Text(String(format: "%.1fU", rate))
  1357. }
  1358. }
  1359. }
  1360. }
  1361. .chartOverlay { proxy in
  1362. GeometryReader { geo in
  1363. Color.clear.preference(
  1364. key: PlotFramePreferenceKey.self,
  1365. value: proxy.plotFrame.map { geo[$0] } ?? .zero
  1366. )
  1367. }
  1368. }
  1369. }
  1370. }
  1371. private struct PlotFramePreferenceKey: PreferenceKey {
  1372. static var defaultValue: CGRect = .zero
  1373. static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
  1374. value = nextValue()
  1375. }
  1376. }
  1377. /// Rendered size of the visible selection pill, reported so the shell can
  1378. /// clamp its position using the real (wrapped) dimensions.
  1379. private struct PillSizePreferenceKey: PreferenceKey {
  1380. static var defaultValue: CGSize = .zero
  1381. static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
  1382. value = nextValue()
  1383. }
  1384. }
  1385. // MARK: - Shared pieces
  1386. private struct DownwardTriangle: ChartSymbolShape {
  1387. var perceptualUnitRect: CGRect { CGRect(x: 0, y: 0, width: 1, height: 1) }
  1388. func path(in rect: CGRect) -> Path {
  1389. var p = Path()
  1390. p.move(to: CGPoint(x: rect.minX, y: rect.minY))
  1391. p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
  1392. p.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
  1393. p.closeSubpath()
  1394. return p
  1395. }
  1396. }
  1397. private struct PillLabel: View {
  1398. /// One entry per selected item. A lone entry keeps its multi-line layout;
  1399. /// several stack as compact one-line-per-item rows so the pill stays
  1400. /// readable over a busy cluster.
  1401. let texts: [String]
  1402. let maxWidth: CGFloat
  1403. var body: some View {
  1404. content
  1405. .padding(.horizontal, 6)
  1406. .padding(.vertical, 3)
  1407. .background(
  1408. RoundedRectangle(cornerRadius: 6)
  1409. .fill(Color(.secondarySystemBackground))
  1410. .overlay(
  1411. RoundedRectangle(cornerRadius: 6)
  1412. .stroke(Color.primary, lineWidth: 0.5)
  1413. )
  1414. )
  1415. .background(
  1416. GeometryReader { geo in
  1417. Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size)
  1418. }
  1419. )
  1420. // Transparent flexible container: it caps the width the text can
  1421. // wrap to, while the visible pill above still hugs its content.
  1422. .frame(maxWidth: maxWidth)
  1423. }
  1424. @ViewBuilder
  1425. private var content: some View {
  1426. if texts.count == 1 {
  1427. // Bound pathological texts; a note this long is better read in
  1428. // Nightscout than on a chart pill.
  1429. entry(texts[0], lineLimit: 10)
  1430. } else {
  1431. VStack(spacing: 3) {
  1432. ForEach(texts.indices, id: \.self) { index in
  1433. if index > 0 {
  1434. // Fixed-width hairline: a Divider would greedily
  1435. // stretch the hugging pill to its max width.
  1436. Rectangle()
  1437. .fill(Color.primary.opacity(0.25))
  1438. .frame(width: 46, height: 0.5)
  1439. }
  1440. // Stacked items collapse to "Bolus 2.5U 14:32" rows.
  1441. entry(texts[index].replacingOccurrences(of: "\n", with: " "), lineLimit: 4)
  1442. }
  1443. }
  1444. }
  1445. }
  1446. private func entry(_ text: String, lineLimit: Int) -> some View {
  1447. Text(text)
  1448. .font(.caption2)
  1449. .foregroundColor(.primary)
  1450. .multilineTextAlignment(.center)
  1451. .lineLimit(lineLimit)
  1452. }
  1453. }