BGChartView.swift 67 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624
  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/drag-to-navigate)
  819. private struct SmallBGChart: View {
  820. @ObservedObject var model: BGChartModel
  821. @ObservedObject var interaction: BGChartInteraction
  822. /// True once the touch has travelled far enough to count as a drag; from
  823. /// then on the main chart tracks the finger live instead of waiting for release.
  824. @State private var isScrubbing = false
  825. var body: some View {
  826. GeometryReader { geo in
  827. let width = max(geo.size.width, 1)
  828. let domainSeconds = max(model.domainEnd.timeIntervalSince(model.domainStart), 1)
  829. ZStack(alignment: .topLeading) {
  830. BGChartCanvas(
  831. model: model,
  832. generation: model.generation,
  833. isSmall: true,
  834. windowStart: model.domainStart,
  835. windowEnd: model.domainStart.addingTimeInterval(domainSeconds),
  836. canvasWidth: width,
  837. height: geo.size.height,
  838. visibleSeconds: domainSeconds,
  839. timeZone: .current
  840. )
  841. .equatable()
  842. viewportBox(width: width, height: geo.size.height, domainSeconds: domainSeconds)
  843. .allowsHitTesting(false)
  844. }
  845. .contentShape(Rectangle())
  846. .gesture(
  847. DragGesture(minimumDistance: 0)
  848. .onChanged { value in
  849. let distance = hypot(value.translation.width, value.translation.height)
  850. if isScrubbing || distance >= BGChartConfig.inspectMovementTolerance {
  851. isScrubbing = true
  852. navigate(toLocalX: value.location.x, width: width, domainSeconds: domainSeconds, animated: false)
  853. }
  854. }
  855. .onEnded { value in
  856. navigate(toLocalX: value.location.x, width: width, domainSeconds: domainSeconds, animated: !isScrubbing)
  857. isScrubbing = false
  858. }
  859. )
  860. }
  861. .background(Color(.systemBackground))
  862. }
  863. /// Box showing where the main chart's visible window currently sits.
  864. private func viewportBox(width: CGFloat, height: CGFloat, domainSeconds: TimeInterval) -> some View {
  865. let startFraction = interaction.scrollPosition.timeIntervalSince(model.domainStart) / domainSeconds
  866. let boxWidth = min(max(2, CGFloat(interaction.visibleSeconds / domainSeconds) * width), width)
  867. let boxLeft = min(max(CGFloat(startFraction) * width, 0), width - boxWidth)
  868. return Rectangle()
  869. .fill(Color.primary.opacity(0.08))
  870. .overlay(Rectangle().stroke(Color.primary.opacity(0.35), lineWidth: 1))
  871. .frame(width: boxWidth, height: height)
  872. .offset(x: boxLeft)
  873. }
  874. /// Maps a touch x to a time and recenters the main chart on it (clamped to
  875. /// the data window). Following re-arms automatically via the main chart's
  876. /// scroll observer if the user lands back at the latest data.
  877. private func navigate(toLocalX x: CGFloat, width: CGFloat, domainSeconds: TimeInterval, animated: Bool) {
  878. let fraction = min(max(x / width, 0), 1)
  879. let date = model.domainStart.addingTimeInterval(domainSeconds * TimeInterval(fraction))
  880. let length = interaction.visibleSeconds
  881. var target = date.addingTimeInterval(-length / 2)
  882. let minStart = model.domainStart
  883. let maxStart = model.domainEnd.addingTimeInterval(-length)
  884. if maxStart > minStart {
  885. target = min(max(target, minStart), maxStart)
  886. }
  887. // Same rule as the main chart's scrollToNow: only nearby targets (still
  888. // inside the render window) animate cleanly; long jumps snap. Live
  889. // drag-tracking never animates — each move must land within the frame.
  890. if animated, abs(target.timeIntervalSince(interaction.scrollPosition)) <= interaction.visibleSeconds {
  891. withAnimation(.easeInOut(duration: 0.3)) {
  892. interaction.scrollPosition = target
  893. }
  894. } else {
  895. interaction.scrollPosition = target
  896. }
  897. }
  898. }
  899. // MARK: - Canvas (laid out once per data / zoom change; translated while panning)
  900. /// The chart content, laid out over the render window at fixed width. Stored
  901. /// properties deliberately exclude the pan position, and the explicit
  902. /// Equatable conformance below compares only cheap metadata (`generation`
  903. /// stands in for the data arrays) — so SwiftUI provably skips this body
  904. /// during panning and momentum.
  905. private struct BGChartCanvas: View, Equatable {
  906. let model: BGChartModel // plain reference on purpose: updates arrive via `generation`
  907. let generation: Int
  908. let isSmall: Bool
  909. let windowStart: Date
  910. let windowEnd: Date
  911. let canvasWidth: CGFloat
  912. let height: CGFloat
  913. let visibleSeconds: TimeInterval
  914. let timeZone: TimeZone
  915. static func == (lhs: BGChartCanvas, rhs: BGChartCanvas) -> Bool {
  916. lhs.generation == rhs.generation &&
  917. lhs.isSmall == rhs.isSmall &&
  918. lhs.windowStart == rhs.windowStart &&
  919. lhs.windowEnd == rhs.windowEnd &&
  920. lhs.canvasWidth == rhs.canvasWidth &&
  921. lhs.height == rhs.height &&
  922. lhs.visibleSeconds == rhs.visibleSeconds &&
  923. lhs.timeZone == rhs.timeZone
  924. }
  925. private var basalScale: Double {
  926. guard model.maxBasal > 0 else { return 0 }
  927. return model.maxBG / model.maxBasal
  928. }
  929. var body: some View {
  930. let showTreatments = !isSmall || model.smallGraphTreatments
  931. let chart = Chart {
  932. if showTreatments {
  933. bgBandMarks
  934. basalMarks
  935. scheduledBasalMarks
  936. }
  937. coneMarks
  938. if !isSmall {
  939. yesterdayMarks
  940. }
  941. bgLineMarks
  942. bgPointsMark
  943. predictionLineMark
  944. predictionVariantMarks
  945. if showTreatments {
  946. treatmentMarks
  947. }
  948. if !isSmall {
  949. ruleMarks
  950. } else if model.showMidnight {
  951. midnightRuleMarks
  952. }
  953. }
  954. .chartXScale(domain: windowStart ... windowEnd)
  955. .chartYScale(domain: 0 ... chartYDomainUpperBound(model.maxBG))
  956. .chartLegend(.hidden)
  957. .chartYAxis(.hidden)
  958. return Group {
  959. if isSmall {
  960. chart.chartXAxis(.hidden)
  961. } else {
  962. chart.chartXAxis {
  963. AxisMarks(values: xAxisMarkDates()) { value in
  964. AxisValueLabel {
  965. if let date = value.as(Date.self) {
  966. xAxisLabel(for: date)
  967. }
  968. }
  969. }
  970. }
  971. }
  972. }
  973. .frame(width: canvasWidth, height: height)
  974. }
  975. // MARK: Windowing
  976. // The mark series sliced to the render window: marks outside it clip
  977. // invisibly but still cost layout, so an unfiltered re-layout does several
  978. // times the work for nothing.
  979. private func windowed<T>(_ items: [T], date: (T) -> Date) -> [T] {
  980. items.filter { item in
  981. let d = date(item)
  982. return d >= windowStart && d <= windowEnd
  983. }
  984. }
  985. /// For line/area series: like `windowed`, but keeps one point beyond each
  986. /// edge so segments crossing (or spanning) the window survive. Assumes
  987. /// ascending dates.
  988. private func windowedLine<T>(_ items: [T], date: (T) -> Date) -> [T] {
  989. guard items.count > 1 else { return items }
  990. let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count
  991. let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1
  992. let lo = max(firstInside - 1, 0)
  993. let hi = min(firstBeyond, items.count - 1)
  994. guard lo <= hi else { return [] }
  995. return Array(items[lo ... hi])
  996. }
  997. // MARK: X axis
  998. /// Grid/label stride ladder for the current zoom level.
  999. private var xAxisStrideSeconds: TimeInterval {
  1000. let visibleHours = visibleSeconds / 3600
  1001. if visibleHours <= 2 { return 1800 }
  1002. if visibleHours <= 6 { return 3600 }
  1003. if visibleHours <= 12 { return 2 * 3600 }
  1004. return 4 * 3600
  1005. }
  1006. /// Axis mark dates anchored to local midnight (in the configured graph
  1007. /// time zone), unlike `.stride(by: .hour)`, which anchors to the domain start.
  1008. private func xAxisMarkDates() -> [Date] {
  1009. var cal = Calendar(identifier: .gregorian)
  1010. cal.timeZone = timeZone
  1011. let stride = xAxisStrideSeconds
  1012. var mark = cal.startOfDay(for: windowStart)
  1013. var marks: [Date] = []
  1014. while mark <= windowEnd {
  1015. if mark >= windowStart {
  1016. marks.append(mark)
  1017. }
  1018. mark = mark.addingTimeInterval(stride)
  1019. }
  1020. return marks
  1021. }
  1022. /// Midnight ticks carry the day ("Tue 7") so panned-back history stays
  1023. /// unambiguous; other ticks show the hour (plus minutes at sub-hour strides).
  1024. private func xAxisLabel(for date: Date) -> some View {
  1025. var cal = Calendar(identifier: .gregorian)
  1026. cal.timeZone = timeZone
  1027. let comps = cal.dateComponents([.hour, .minute], from: date)
  1028. return Group {
  1029. if comps.hour == 0, comps.minute == 0 {
  1030. Text(date, format: .dateTime.weekday(.abbreviated).day())
  1031. } else if xAxisStrideSeconds < 3600 {
  1032. Text(date, format: .dateTime.hour().minute())
  1033. } else {
  1034. Text(date, format: .dateTime.hour())
  1035. }
  1036. }
  1037. .font(.footnote)
  1038. .environment(\.timeZone, timeZone)
  1039. }
  1040. // MARK: Marks
  1041. @ChartContentBuilder
  1042. private var bgBandMarks: some ChartContent {
  1043. ForEach(model.overrides.filter { $0.end >= windowStart && $0.start <= windowEnd }) { band in
  1044. RectangleMark(
  1045. xStart: .value("start", band.start),
  1046. xEnd: .value("end", band.end),
  1047. yStart: .value("yBottom", band.yBottom),
  1048. yEnd: .value("yTop", band.yTop)
  1049. )
  1050. .foregroundStyle(model.overrideColor.opacity(0.6))
  1051. }
  1052. ForEach(model.tempTargets.filter { $0.end >= windowStart && $0.start <= windowEnd }) { band in
  1053. RectangleMark(
  1054. xStart: .value("start", band.start),
  1055. xEnd: .value("end", band.end),
  1056. yStart: .value("yBottom", band.yBottom),
  1057. yEnd: .value("yTop", band.yTop)
  1058. )
  1059. .foregroundStyle(model.tempTargetColor.opacity(0.5))
  1060. }
  1061. }
  1062. @ChartContentBuilder
  1063. private var basalMarks: some ChartContent {
  1064. ForEach(model.basal.filter { $0.end >= windowStart && $0.start <= windowEnd }) { step in
  1065. RectangleMark(
  1066. xStart: .value("start", step.start),
  1067. xEnd: .value("end", step.end),
  1068. yStart: .value("yBottom", 0.0),
  1069. yEnd: .value("rate", step.rate * basalScale)
  1070. )
  1071. .foregroundStyle(.blue.opacity(0.35))
  1072. }
  1073. }
  1074. @ChartContentBuilder
  1075. private var scheduledBasalMarks: some ChartContent {
  1076. ForEach(windowedLine(model.basalScheduled) { $0.date }) { pt in
  1077. LineMark(
  1078. x: .value("time", pt.date),
  1079. y: .value("rate", pt.rate * basalScale),
  1080. series: .value("series", "basalScheduled")
  1081. )
  1082. .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
  1083. .foregroundStyle(Color.blue.opacity(0.8))
  1084. }
  1085. }
  1086. @ChartContentBuilder
  1087. private var coneMarks: some ChartContent {
  1088. ForEach(windowedLine(model.cone) { $0.date }) { pt in
  1089. AreaMark(
  1090. x: .value("time", pt.date),
  1091. yStart: .value("yMin", pt.yMin),
  1092. yEnd: .value("yMax", pt.yMax)
  1093. )
  1094. // Same fill as Trio's cone.
  1095. .foregroundStyle(Color(.systemBlue).opacity(0.4))
  1096. .interpolationMethod(.monotone)
  1097. }
  1098. }
  1099. @ChartContentBuilder
  1100. private var yesterdayMarks: some ChartContent {
  1101. ForEach(windowedLine(model.yesterday) { $0.date }) { pt in
  1102. LineMark(
  1103. x: .value("time", pt.date),
  1104. y: .value("bg", pt.value),
  1105. series: .value("series", "yesterday")
  1106. )
  1107. .foregroundStyle(Color(.systemGray).opacity(0.4))
  1108. .lineStyle(StrokeStyle(lineWidth: 1.5))
  1109. .interpolationMethod(.linear)
  1110. }
  1111. }
  1112. @ChartContentBuilder
  1113. private var bgLineMarks: some ChartContent {
  1114. if model.showLines {
  1115. ForEach(model.bgRuns) { run in
  1116. if let first = run.points.first, let last = run.points.last,
  1117. last.date >= windowStart, first.date <= windowEnd
  1118. {
  1119. ForEach(windowedLine(run.points) { $0.date }) { pt in
  1120. LineMark(
  1121. x: .value("time", pt.date),
  1122. y: .value("bg", pt.value),
  1123. series: .value("series", "bg-\(run.id)")
  1124. )
  1125. .foregroundStyle(run.color)
  1126. .lineStyle(StrokeStyle(lineWidth: 2.5))
  1127. .interpolationMethod(.linear)
  1128. }
  1129. }
  1130. }
  1131. }
  1132. }
  1133. @ChartContentBuilder
  1134. private var bgPointsMark: some ChartContent {
  1135. if model.showDots {
  1136. ForEach(windowed(model.bg) { $0.date }) { pt in
  1137. PointMark(
  1138. x: .value("time", pt.date),
  1139. y: .value("bg", pt.value)
  1140. )
  1141. .symbolSize(isSmall ? 14 : 30)
  1142. .foregroundStyle(pt.color)
  1143. }
  1144. }
  1145. }
  1146. @ChartContentBuilder
  1147. private var predictionLineMark: some ChartContent {
  1148. ForEach(windowedLine(model.prediction) { $0.date }) { pt in
  1149. LineMark(
  1150. x: .value("time", pt.date),
  1151. y: .value("bg", pt.value),
  1152. series: .value("series", "prediction")
  1153. )
  1154. .foregroundStyle(.purple)
  1155. .lineStyle(StrokeStyle(lineWidth: 2))
  1156. }
  1157. }
  1158. @ChartContentBuilder
  1159. private var predictionVariantMarks: some ChartContent {
  1160. ForEach(windowedLine(model.ztPrediction) { $0.date }) { pt in
  1161. LineMark(
  1162. x: .value("time", pt.date),
  1163. y: .value("bg", pt.value),
  1164. series: .value("series", "zt")
  1165. )
  1166. .foregroundStyle(Color("ZT"))
  1167. .lineStyle(StrokeStyle(lineWidth: 2))
  1168. }
  1169. ForEach(windowedLine(model.iobPrediction) { $0.date }) { pt in
  1170. LineMark(
  1171. x: .value("time", pt.date),
  1172. y: .value("bg", pt.value),
  1173. series: .value("series", "iob")
  1174. )
  1175. .foregroundStyle(Color("Insulin"))
  1176. .lineStyle(StrokeStyle(lineWidth: 2))
  1177. }
  1178. ForEach(windowedLine(model.cobPrediction) { $0.date }) { pt in
  1179. LineMark(
  1180. x: .value("time", pt.date),
  1181. y: .value("bg", pt.value),
  1182. series: .value("series", "cob")
  1183. )
  1184. .foregroundStyle(Color(.systemOrange))
  1185. .lineStyle(StrokeStyle(lineWidth: 2))
  1186. }
  1187. ForEach(windowedLine(model.uamPrediction) { $0.date }) { pt in
  1188. LineMark(
  1189. x: .value("time", pt.date),
  1190. y: .value("bg", pt.value),
  1191. series: .value("series", "uam")
  1192. )
  1193. .foregroundStyle(Color("UAM"))
  1194. .lineStyle(StrokeStyle(lineWidth: 2))
  1195. }
  1196. }
  1197. @ChartContentBuilder
  1198. private var treatmentMarks: some ChartContent {
  1199. ForEach(windowed(model.boluses) { $0.drawnDate }) { pt in
  1200. PointMark(
  1201. x: .value("time", pt.drawnDate),
  1202. y: .value("sgv", pt.sgv)
  1203. )
  1204. .symbolSize(isSmall ? 24 : 64)
  1205. .foregroundStyle(Color.blue.opacity(0.75))
  1206. .annotation(position: .top, alignment: .center) {
  1207. if !isSmall, Storage.shared.showValues.value {
  1208. Text(pt.label).font(.caption2).foregroundColor(.primary)
  1209. }
  1210. }
  1211. }
  1212. ForEach(windowed(model.carbs) { $0.drawnDate }) { pt in
  1213. PointMark(
  1214. x: .value("time", pt.drawnDate),
  1215. y: .value("sgv", pt.sgv)
  1216. )
  1217. .symbolSize(isSmall ? 24 : 64)
  1218. .foregroundStyle(Color.orange.opacity(0.75))
  1219. .annotation(position: .top, alignment: .center) {
  1220. if !isSmall, Storage.shared.showValues.value {
  1221. Text(pt.label).font(.caption2).foregroundColor(.primary)
  1222. }
  1223. }
  1224. }
  1225. ForEach(windowed(model.smbs) { $0.drawnDate }) { pt in
  1226. PointMark(
  1227. x: .value("time", pt.drawnDate),
  1228. y: .value("sgv", pt.sgv)
  1229. )
  1230. .symbol(DownwardTriangle())
  1231. .symbolSize(isSmall ? 28 : 64)
  1232. .foregroundStyle(Color.blue.opacity(0.75))
  1233. .annotation(position: .top, alignment: .center) {
  1234. if !isSmall, Storage.shared.showValues.value {
  1235. Text(pt.label).font(.caption2).foregroundColor(.primary)
  1236. }
  1237. }
  1238. }
  1239. ForEach(windowed(model.bgChecks) { $0.drawnDate }) { pt in
  1240. PointMark(
  1241. x: .value("time", pt.drawnDate),
  1242. y: .value("sgv", pt.sgv)
  1243. )
  1244. .symbolSize(isSmall ? 22 : 54)
  1245. .foregroundStyle(Color.red.opacity(0.75))
  1246. }
  1247. ForEach(windowed(model.suspends) { $0.drawnDate }) { pt in
  1248. PointMark(
  1249. x: .value("time", pt.drawnDate),
  1250. y: .value("sgv", pt.sgv)
  1251. )
  1252. .symbol(.square)
  1253. .symbolSize(isSmall ? 22 : 54)
  1254. .foregroundStyle(Color.teal.opacity(0.75))
  1255. }
  1256. ForEach(windowed(model.resumes) { $0.drawnDate }) { pt in
  1257. PointMark(
  1258. x: .value("time", pt.drawnDate),
  1259. y: .value("sgv", pt.sgv)
  1260. )
  1261. .symbol(.square)
  1262. .symbolSize(isSmall ? 22 : 54)
  1263. .foregroundStyle(Color.teal.opacity(0.5))
  1264. }
  1265. ForEach(windowed(model.sensorStarts) { $0.drawnDate }) { pt in
  1266. PointMark(
  1267. x: .value("time", pt.drawnDate),
  1268. y: .value("sgv", pt.sgv)
  1269. )
  1270. .symbol(.cross)
  1271. .symbolSize(isSmall ? 22 : 54)
  1272. .foregroundStyle(Color.indigo.opacity(0.75))
  1273. }
  1274. ForEach(windowed(model.notes) { $0.drawnDate }) { pt in
  1275. PointMark(
  1276. x: .value("time", pt.drawnDate),
  1277. y: .value("sgv", pt.sgv)
  1278. )
  1279. .symbolSize(isSmall ? 22 : 54)
  1280. .foregroundStyle(Color.gray.opacity(0.75))
  1281. }
  1282. }
  1283. @ChartContentBuilder
  1284. private var ruleMarks: some ChartContent {
  1285. RuleMark(y: .value("low", model.lowLine))
  1286. .lineStyle(StrokeStyle(lineWidth: 2))
  1287. .foregroundStyle(Color.red.opacity(0.5))
  1288. RuleMark(y: .value("high", model.highLine))
  1289. .lineStyle(StrokeStyle(lineWidth: 2))
  1290. .foregroundStyle(Color.yellow.opacity(0.5))
  1291. if model.now >= windowStart, model.now <= windowEnd {
  1292. RuleMark(x: .value("now", model.now))
  1293. .lineStyle(StrokeStyle(lineWidth: 1))
  1294. .foregroundStyle(Color.gray.opacity(0.6))
  1295. }
  1296. if model.show30Min, let d = model.thirtyMinMark, d >= windowStart, d <= windowEnd {
  1297. RuleMark(x: .value("30m", d))
  1298. .lineStyle(StrokeStyle(lineWidth: 0.5))
  1299. .foregroundStyle(Color.blue.opacity(0.7))
  1300. }
  1301. if model.show90Min, let d = model.ninetyMinMark, d >= windowStart, d <= windowEnd {
  1302. RuleMark(x: .value("90m", d))
  1303. .lineStyle(StrokeStyle(lineWidth: 0.5))
  1304. .foregroundStyle(Color.orange.opacity(0.7))
  1305. }
  1306. if model.showDIA {
  1307. ForEach(model.diaMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in
  1308. RuleMark(x: .value("dia", d))
  1309. .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2]))
  1310. .foregroundStyle(Color.gray.opacity(0.5))
  1311. }
  1312. }
  1313. if model.showMidnight {
  1314. midnightRuleMarks
  1315. }
  1316. }
  1317. @ChartContentBuilder
  1318. private var midnightRuleMarks: some ChartContent {
  1319. ForEach(model.midnightMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in
  1320. RuleMark(x: .value("midnight", d))
  1321. .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [5, 3]))
  1322. .foregroundStyle(Color.teal.opacity(0.6))
  1323. }
  1324. }
  1325. }
  1326. // MARK: - Pinned y-axis overlay
  1327. /// Renders only the y-axes — BG values trailing, basal rates leading — at a
  1328. /// fixed position over the scrolling canvas, plus a phantom x-axis label that
  1329. /// reserves exactly the height of the canvas's hour labels so both plots end
  1330. /// at the same y. Also reports its plot frame so the shell's selection
  1331. /// overlay shares the same value-to-pixel mapping.
  1332. private struct StaticYAxisOverlay: View, Equatable {
  1333. let generation: Int
  1334. let maxBG: Double
  1335. let maxBasal: Double
  1336. private var basalScale: Double {
  1337. guard maxBasal > 0 else { return 0 }
  1338. return maxBG / maxBasal
  1339. }
  1340. private var leadingBasalTicks: [Double] {
  1341. guard basalScale > 0 else { return [] }
  1342. let ticks = stride(from: 0.0, through: maxBasal, by: max(0.5, maxBasal / 4))
  1343. .map { $0 * basalScale }
  1344. return Array(ticks)
  1345. }
  1346. var body: some View {
  1347. Chart {
  1348. // Invisible content at the domain corners so the scales resolve
  1349. // and the plot (and with it the axes) materializes.
  1350. PointMark(x: .value("edge", 0.0), y: .value("min", 0.0))
  1351. .opacity(0)
  1352. PointMark(x: .value("edge", 1.0), y: .value("max", maxBG))
  1353. .opacity(0)
  1354. }
  1355. .chartXScale(domain: 0 ... 1)
  1356. .chartYScale(domain: 0 ... chartYDomainUpperBound(maxBG))
  1357. .chartLegend(.hidden)
  1358. .chartXAxis {
  1359. AxisMarks(values: [0.5]) { _ in
  1360. AxisValueLabel {
  1361. Text("00").font(.footnote).foregroundStyle(.clear)
  1362. }
  1363. }
  1364. }
  1365. .chartYAxis {
  1366. AxisMarks(position: .trailing) { value in
  1367. AxisTick()
  1368. AxisValueLabel {
  1369. if let v = value.as(Double.self) {
  1370. Text(Localizer.toDisplayUnits(String(v)))
  1371. }
  1372. }
  1373. }
  1374. AxisMarks(position: .leading, values: leadingBasalTicks) { value in
  1375. AxisTick()
  1376. AxisValueLabel {
  1377. if let v = value.as(Double.self) {
  1378. let rate = basalScale > 0 ? v / basalScale : 0
  1379. Text(String(format: "%.1fU", rate))
  1380. }
  1381. }
  1382. }
  1383. }
  1384. .chartOverlay { proxy in
  1385. GeometryReader { geo in
  1386. Color.clear.preference(
  1387. key: PlotFramePreferenceKey.self,
  1388. value: proxy.plotFrame.map { geo[$0] } ?? .zero
  1389. )
  1390. }
  1391. }
  1392. }
  1393. }
  1394. private struct PlotFramePreferenceKey: PreferenceKey {
  1395. static var defaultValue: CGRect = .zero
  1396. static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
  1397. value = nextValue()
  1398. }
  1399. }
  1400. /// Rendered size of the visible selection pill, reported so the shell can
  1401. /// clamp its position using the real (wrapped) dimensions.
  1402. private struct PillSizePreferenceKey: PreferenceKey {
  1403. static var defaultValue: CGSize = .zero
  1404. static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
  1405. value = nextValue()
  1406. }
  1407. }
  1408. // MARK: - Shared pieces
  1409. private struct DownwardTriangle: ChartSymbolShape {
  1410. var perceptualUnitRect: CGRect { CGRect(x: 0, y: 0, width: 1, height: 1) }
  1411. func path(in rect: CGRect) -> Path {
  1412. var p = Path()
  1413. p.move(to: CGPoint(x: rect.minX, y: rect.minY))
  1414. p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
  1415. p.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
  1416. p.closeSubpath()
  1417. return p
  1418. }
  1419. }
  1420. private struct PillLabel: View {
  1421. /// One entry per selected item. A lone entry keeps its multi-line layout;
  1422. /// several stack as compact one-line-per-item rows so the pill stays
  1423. /// readable over a busy cluster.
  1424. let texts: [String]
  1425. let maxWidth: CGFloat
  1426. var body: some View {
  1427. content
  1428. .padding(.horizontal, 6)
  1429. .padding(.vertical, 3)
  1430. .background(
  1431. RoundedRectangle(cornerRadius: 6)
  1432. .fill(Color(.secondarySystemBackground))
  1433. .overlay(
  1434. RoundedRectangle(cornerRadius: 6)
  1435. .stroke(Color.primary, lineWidth: 0.5)
  1436. )
  1437. )
  1438. .background(
  1439. GeometryReader { geo in
  1440. Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size)
  1441. }
  1442. )
  1443. // Transparent flexible container: it caps the width the text can
  1444. // wrap to, while the visible pill above still hugs its content.
  1445. .frame(maxWidth: maxWidth)
  1446. }
  1447. @ViewBuilder
  1448. private var content: some View {
  1449. if texts.count == 1 {
  1450. // Bound pathological texts; a note this long is better read in
  1451. // Nightscout than on a chart pill.
  1452. entry(texts[0], lineLimit: 10)
  1453. } else {
  1454. VStack(spacing: 3) {
  1455. ForEach(texts.indices, id: \.self) { index in
  1456. if index > 0 {
  1457. // Fixed-width hairline: a Divider would greedily
  1458. // stretch the hugging pill to its max width.
  1459. Rectangle()
  1460. .fill(Color.primary.opacity(0.25))
  1461. .frame(width: 46, height: 0.5)
  1462. }
  1463. // Stacked items collapse to "Bolus 2.5U 14:32" rows.
  1464. entry(texts[index].replacingOccurrences(of: "\n", with: " "), lineLimit: 4)
  1465. }
  1466. }
  1467. }
  1468. }
  1469. private func entry(_ text: String, lineLimit: Int) -> some View {
  1470. Text(text)
  1471. .font(.caption2)
  1472. .foregroundColor(.primary)
  1473. .multilineTextAlignment(.center)
  1474. .lineLimit(lineLimit)
  1475. }
  1476. }