| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624 |
- // LoopFollow
- // BGChartView.swift
- import Charts
- import SwiftUI
- /// Shared generator for the light tick emitted when a scrub lands on a new
- /// anchor. File-scoped so it stays prepared across ticks instead of being
- /// re-created per body evaluation.
- private let scrubHaptic = UISelectionFeedbackGenerator()
- private enum BGChartConfig {
- /// Tightest pinch-in zoom.
- static let minVisibleSeconds: TimeInterval = 15 * 60
- /// Widest pinch-out zoom.
- static let maxVisibleSeconds: TimeInterval = 24 * 3600
- /// Double-tap cycles the visible window through these presets.
- static let zoomPresets: [TimeInterval] = [3600, 3 * 3600, 6 * 3600, 12 * 3600, 24 * 3600]
- /// Anchor of the geometric grid pinch commits quantize to.
- static let zoomGridBaseSeconds: TimeInterval = 3600
- /// Geometric grid for pinch commits (~4 % per step). Every committed zoom
- /// step re-lays the canvas, so this bounds a halving of the visible window
- /// to roughly 18 re-layouts instead of hundreds.
- static let zoomStepRatio: Double = 1.04
- /// Live pinch previews as a transform; once the stretch drifts past this
- /// ratio a crisp re-layout is committed mid-gesture, bounding distortion.
- static let pinchCommitScaleDrift: Double = 1.25
- /// Render window extends this many visible-windows beyond each visible edge.
- static let renderWindowPadFactor = 1.5
- /// Re-anchor when a visible edge gets within this fraction of a
- /// visible-window of the render window's edge.
- static let renderWindowMarginFactor = 0.5
- /// How far (pt) a one-finger touch may travel and still count as a
- /// stationary press-to-inspect; beyond this the touch becomes a pan.
- static let inspectMovementTolerance: CGFloat = 10
- /// How long (s) a one-finger touch must rest before inspect latches.
- static let inspectHoldDelay: TimeInterval = 0.2
- /// How close the visible window's trailing edge must sit to "now" before
- /// we treat the user as "at the latest data" and resume auto-following.
- static let followEdgeTolerance: TimeInterval = 90
- /// While following, "now" sits at this fraction of the visible window,
- /// leaving room on the right for predictions/cone.
- static let followNowFraction = 0.7
- /// How long after the last navigation in history before a data tick pulls
- /// the chart back to "now".
- static let autoFollowPause: TimeInterval = 5 * 60
- /// Max distance between the scrub date and an anchor for it to be selected.
- static let selectionTolerance: TimeInterval = 20 * 60
- /// Half-width (pt) of the scrub capture band: treatments whose symbol is
- /// within this screen distance of the finger join the pill alongside the
- /// (ever-present) nearest BG reading.
- static let scrubCaptureRadius: CGFloat = 22
- /// Time cap on the capture band, so wide zooms — where a finger-width
- /// covers hours — don't sweep far-away treatments into the pill.
- static let scrubCaptureMaxSeconds: TimeInterval = 5 * 60
- /// Screen-space radius (pt) within which a tap selects a mark.
- static let tapHitRadius: CGFloat = 30
- }
- /// Small y-domain headroom keeps the top axis label readable instead of
- /// pinning it to the chart edge.
- private func chartYDomainUpperBound(_ maxBG: Double) -> Double {
- let clampedMax = max(maxBG, 1)
- let topPadding = max(clampedMax * 0.02, 1)
- return clampedMax + topPadding
- }
- struct BGChartView: View {
- enum Config {
- case small
- case main
- }
- let model: BGChartModel
- let config: Config
- var body: some View {
- if config == .small {
- SmallBGChart(model: model, interaction: model.interaction)
- } else {
- MainBGChart(model: model, interaction: model.interaction)
- }
- }
- }
- // MARK: - Main chart shell (gestures, transforms, overlays)
- /// The interactive BG chart.
- ///
- /// Rendering/interaction strategy: the chart content is
- /// laid out ONCE per (data, zoom) change onto a wide fixed-width canvas
- /// covering a render window around the visible viewport. Panning translates
- /// that canvas with a pure `.offset` transform — the canvas is an Equatable
- /// child whose stored properties exclude the pan position, so SwiftUI skips
- /// its body during pans and momentum. Zoom is a pure-SwiftUI MagnifyGesture
- /// whose live preview is a `.scaleEffect(x:)` stretch anchored under the
- /// pinch centroid, committed on a geometric zoom grid. A one-finger press
- /// held stationary latches into inspect mode and scrubs a selection that is
- /// rendered by a shell overlay (never re-laying the canvas). Double-tap
- /// cycles zoom presets. No `.chartScrollableAxes`, no UIKit gesture hacks.
- private struct MainBGChart: View {
- @ObservedObject var model: BGChartModel
- @ObservedObject var interaction: BGChartInteraction
- @State private var didInitialize = false
- /// Rendered slice of the domain. The canvas covers only this window
- /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width
- /// and per-layout cost no matter how long the data domain grows.
- @State private var renderWindowStart = Date()
- @State private var renderWindowEnd = Date().addingTimeInterval(3600)
- /// Plot area of the static axis overlay, in shell coordinates. The
- /// selection overlay uses it for its value-to-pixel maps.
- @State private var plotFrame: CGRect = .zero
- /// Measured size of the visible selection pill (see PillSizePreferenceKey).
- @State private var pillSize: CGSize = .zero
- // Pinch state: live preview stretch plus the anchor captured at pinch
- // start so the zoom stays anchored under the pinch centroid.
- @State private var pinchScale: CGFloat = 1
- @State private var pinchAnchor: (
- visibleAtStart: TimeInterval,
- anchorDate: Date,
- anchorFraction: CGFloat
- )?
- /// Leading edge captured when a one-finger drag transitions into panning.
- @State private var panBaseline: Date?
- /// Date under the user's finger while inspecting, else nil.
- @State private var selection: Date?
- /// Anchor selected by tapping a mark; sticky until the user taps empty
- /// space, taps another mark, or starts a pan/zoom/inspect.
- @State private var tapped: SelectionAnchor?
- /// True once a held press has engaged inspect; from then on finger
- /// movement scrubs the selection instead of panning, until the finger lifts.
- @State private var isInspectLatched = false
- /// Most recent finger location, so the hold timer can place the selection
- /// even if the finger produced no further events after touch-down.
- @State private var lastTouchLocation: CGPoint?
- /// Armed at touch-down; fires after `inspectHoldDelay` and latches inspect
- /// if the touch is still down and stationary. A timer is required because
- /// DragGesture only reports *changes* — a perfectly still finger generates
- /// no events after touch-down.
- @State private var inspectHoldTask: Task<Void, Never>?
- @State private var touchDownTime: Date?
- /// Drives post-flick deceleration; cancelled by any new touch.
- @State private var momentumTask: Task<Void, Never>?
- /// Anchor date of the last haptic tick, so scrub jitter doesn't re-fire it.
- @State private var lastHapticAnchorDate: Date?
- /// While the user is (or recently was) navigating history, auto-return to
- /// "now" is paused until this instant. Refreshed by every scroll movement
- /// away from the live edge, cleared on return to it.
- @State private var autoFollowPausedUntil: Date?
- private var timeZoneForAxis: TimeZone {
- if Storage.shared.graphTimeZoneEnabled.value,
- let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value)
- {
- return tz
- }
- return .current
- }
- var body: some View {
- GeometryReader { geo in
- chart(viewport: geo.size)
- }
- .background(Color(.systemBackground))
- }
- private func chart(viewport: CGSize) -> some View {
- let viewportWidth = max(viewport.width, 1)
- let windowSeconds = max(renderWindowEnd.timeIntervalSince(renderWindowStart), 1)
- let canvasWidth = viewportWidth * CGFloat(windowSeconds / interaction.visibleSeconds)
- // Pixel offset of the canvas for the current leading-edge date.
- // Derived, not stored: re-anchoring the window recomputes it consistently.
- let canvasOffsetX = CGFloat(
- interaction.scrollPosition.timeIntervalSince(renderWindowStart) / windowSeconds
- ) * canvasWidth
- return ZStack(alignment: .topLeading) {
- BGChartCanvas(
- model: model,
- generation: model.generation,
- isSmall: false,
- windowStart: renderWindowStart,
- windowEnd: renderWindowEnd,
- canvasWidth: canvasWidth,
- height: viewport.height,
- visibleSeconds: interaction.visibleSeconds,
- timeZone: timeZoneForAxis
- )
- .equatable()
- .offset(x: -canvasOffsetX)
- .scaleEffect(
- x: pinchScale,
- y: 1,
- anchor: pinchScaleAnchor(viewportWidth: viewportWidth, canvasWidth: canvasWidth)
- )
- // Pinned y-axis labels (BG trailing, basal leading) over the
- // scrolling canvas. The canvas's own y-axis is hidden — its
- // trailing edge sits far off-screen on the wide canvas.
- StaticYAxisOverlay(generation: model.generation, maxBG: model.maxBG, maxBasal: model.maxBasal)
- .equatable()
- .frame(width: viewportWidth, height: viewport.height)
- .allowsHitTesting(false)
- selectionOverlay(viewportWidth: viewportWidth)
- .allowsHitTesting(false)
- overrideBandLabelsOverlay(viewportWidth: viewportWidth)
- .allowsHitTesting(false)
- if !interaction.followLatest {
- jumpToNowButton
- }
- }
- .frame(width: viewportWidth, height: viewport.height, alignment: .topLeading)
- .clipped()
- .contentShape(Rectangle())
- .simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth))
- .simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth))
- // Double-tap zooms; a single tap (only recognized once the double-tap
- // window lapses) selects the mark under the finger, or clears the pill.
- .simultaneousGesture(
- TapGesture(count: 2)
- .exclusively(before: SpatialTapGesture())
- .onEnded { value in
- switch value {
- case .first:
- cycleZoomPreset()
- case let .second(tap):
- handleTap(at: tap.location, viewportWidth: viewportWidth)
- }
- }
- )
- .onPreferenceChange(PlotFramePreferenceKey.self) { plotFrame = $0 }
- .onPreferenceChange(PillSizePreferenceKey.self) { pillSize = $0 }
- .onChange(of: interaction.scrollPosition) { _, _ in
- updateRenderWindow()
- updateFollowState()
- }
- .onChange(of: interaction.visibleSeconds) { _, _ in
- updateRenderWindow(force: true)
- }
- .onChange(of: model.now) { _, _ in
- if interaction.followLatest {
- scrollToNow(animated: true)
- } else if let pausedUntil = autoFollowPausedUntil, Date() >= pausedUntil {
- // Idle in history past the pause: the next data tick pulls the
- // chart back to live.
- scrollToNow(animated: true)
- }
- updateRenderWindow()
- }
- .onAppear {
- if !didInitialize {
- didInitialize = true
- scrollToNow(animated: false)
- updateRenderWindow(force: true)
- }
- }
- .onDisappear {
- momentumTask?.cancel()
- inspectHoldTask?.cancel()
- }
- }
- private var jumpToNowButton: some View {
- Button {
- scrollToNow(animated: true)
- } label: {
- Image(systemName: "arrow.right.to.line")
- .font(.footnote.bold())
- .foregroundColor(.primary)
- .padding(8)
- .background(Circle().fill(Color(.secondarySystemBackground)))
- .overlay(Circle().stroke(Color.primary.opacity(0.25), lineWidth: 0.5))
- }
- .padding(.trailing, 44)
- .padding(.bottom, 28)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
- }
- // MARK: Render window / follow state
- /// Re-anchors the render window when the visible window nears its edge.
- /// Between re-anchors, panning stays a pure offset transform.
- private func updateRenderWindow(force: Bool = false) {
- let visibleSeconds = interaction.visibleSeconds
- let pad = BGChartConfig.renderWindowPadFactor * visibleSeconds
- let margin = BGChartConfig.renderWindowMarginFactor * visibleSeconds
- let domainStart = model.domainStart
- let domainEnd = max(model.domainEnd, domainStart.addingTimeInterval(1))
- let visibleStart = max(interaction.scrollPosition, domainStart)
- // Before the first rebuild the scroll position can sit entirely outside
- // the (not yet populated) domain; never let the window invert.
- let visibleEnd = max(
- min(interaction.scrollPosition.addingTimeInterval(visibleSeconds), domainEnd),
- visibleStart
- )
- let nearLeft = visibleStart.timeIntervalSince(renderWindowStart) < margin
- && renderWindowStart > domainStart
- let nearRight = renderWindowEnd.timeIntervalSince(visibleEnd) < margin
- && renderWindowEnd < domainEnd
- let uncovered = visibleStart < renderWindowStart || visibleEnd > renderWindowEnd
- guard force || nearLeft || nearRight || uncovered else { return }
- let newStart = max(visibleStart.addingTimeInterval(-pad), domainStart)
- let newEnd = max(
- min(visibleEnd.addingTimeInterval(pad), domainEnd),
- newStart.addingTimeInterval(1)
- )
- guard newStart != renderWindowStart || newEnd != renderWindowEnd else { return }
- renderWindowStart = newStart
- renderWindowEnd = newEnd
- }
- /// Re-arms following only when the visible window's trailing edge has
- /// caught back up to "now"; otherwise the user is in history, and every
- /// movement there refreshes the auto-return pause.
- private func updateFollowState() {
- let trailingEdge = interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds)
- let follow = trailingEdge >= model.now.addingTimeInterval(-BGChartConfig.followEdgeTolerance)
- autoFollowPausedUntil = follow ? nil : Date().addingTimeInterval(BGChartConfig.autoFollowPause)
- if interaction.followLatest != follow {
- interaction.followLatest = follow
- }
- }
- /// Clamps a proposed leading edge so the visible window never leaves the domain.
- private func clampedLeadingEdge(_ proposed: Date) -> Date {
- // Before the first rebuild the domain is an epoch-0 placeholder;
- // clamping against it would pin the chart to "the beginning of time"
- // and (via the follow observer) disarm auto-following for good.
- guard model.domainEnd > model.domainStart.addingTimeInterval(1) else { return proposed }
- let earliest = model.domainStart
- let latest = model.domainEnd.addingTimeInterval(-interaction.visibleSeconds)
- return min(max(proposed, earliest), max(earliest, latest))
- }
- /// Positions "now" at `followNowFraction` of the visible window and keeps
- /// following armed. Never yanks the chart out from under an active gesture
- /// or momentum; the next data tick re-anchors as usual.
- private func scrollToNow(animated: Bool) {
- guard pinchAnchor == nil, panBaseline == nil, !isInspectLatched, momentumTask == nil else { return }
- interaction.followLatest = true
- let target = clampedLeadingEdge(
- model.now.addingTimeInterval(-interaction.visibleSeconds * BGChartConfig.followNowFraction)
- )
- // Animate only moves the render window already covers (its padding
- // guarantees a window around the visible edges): the offset transform
- // then interpolates over unchanged canvas content. A longer move
- // re-anchors the window, which would swap the content out from under
- // the in-flight animation — so deep returns snap instead.
- let distance = abs(target.timeIntervalSince(interaction.scrollPosition))
- if animated, distance <= interaction.visibleSeconds {
- withAnimation(.easeInOut(duration: 0.3)) {
- interaction.scrollPosition = target
- }
- } else {
- interaction.scrollPosition = target
- }
- }
- // MARK: Gestures
- private var isPinching: Bool { pinchAnchor != nil }
- /// Converts a horizontal translation (pt) into a time delta within the visible window.
- private func timeDelta(forTranslation dx: CGFloat, viewportWidth: CGFloat) -> TimeInterval {
- TimeInterval(dx / viewportWidth) * interaction.visibleSeconds
- }
- /// One-finger gesture: movement pans (with momentum on release); a press
- /// held in place latches into inspect, after which dragging scrubs the
- /// selection until the finger lifts. Panning only mutates scrollPosition
- /// (a transform) and scrubbing only the shell overlay, so neither path
- /// re-lays the canvas.
- private func panAndInspectGesture(viewportWidth: CGFloat) -> some Gesture {
- DragGesture(minimumDistance: 0)
- .onChanged { value in
- momentumTask?.cancel()
- momentumTask = nil
- guard !isPinching else {
- inspectHoldTask?.cancel()
- if selection != nil { selection = nil }
- panBaseline = nil
- touchDownTime = nil
- isInspectLatched = false
- return
- }
- lastTouchLocation = value.location
- if touchDownTime == nil {
- touchDownTime = value.time
- scheduleInspectHold(viewportWidth: viewportWidth)
- }
- if isInspectLatched {
- updateSelection(atViewportX: value.location.x, viewportWidth: viewportWidth)
- return
- }
- let distance = hypot(value.translation.width, value.translation.height)
- if panBaseline == nil, distance < BGChartConfig.inspectMovementTolerance {
- // Finger is stationary: the hold timer armed at touch-down
- // will latch inspect if it stays that way.
- } else {
- // Finger is travelling: pan. The touch can no longer become an inspect.
- inspectHoldTask?.cancel()
- if selection != nil { selection = nil }
- if tapped != nil { tapped = nil }
- if panBaseline == nil {
- // Compensate for the distance already travelled inside the
- // tolerance so the pan engages without a positional jump.
- panBaseline = interaction.scrollPosition.addingTimeInterval(
- timeDelta(forTranslation: value.translation.width, viewportWidth: viewportWidth)
- )
- }
- if let baseline = panBaseline {
- interaction.scrollPosition = clampedLeadingEdge(
- baseline.addingTimeInterval(
- -timeDelta(forTranslation: value.translation.width, viewportWidth: viewportWidth)
- )
- )
- }
- }
- }
- .onEnded { value in
- inspectHoldTask?.cancel()
- if selection != nil { selection = nil }
- touchDownTime = nil
- isInspectLatched = false
- lastTouchLocation = nil
- lastHapticAnchorDate = nil
- let wasPanning = panBaseline != nil
- panBaseline = nil
- guard wasPanning, !isPinching else { return }
- // Momentum: initial velocity in seconds of chart time per second.
- let velocity = -timeDelta(forTranslation: value.velocity.width, viewportWidth: viewportWidth)
- startMomentum(velocitySecondsPerSecond: velocity)
- }
- }
- /// Arms the inspect hold: after `inspectHoldDelay`, if the touch is still
- /// down and has neither become a pan nor a pinch, latch into inspect mode
- /// at the finger's last known position — with a haptic so the mode change is felt.
- private func scheduleInspectHold(viewportWidth: CGFloat) {
- inspectHoldTask?.cancel()
- inspectHoldTask = Task { @MainActor in
- try? await Task.sleep(nanoseconds: UInt64(BGChartConfig.inspectHoldDelay * 1_000_000_000))
- guard !Task.isCancelled,
- touchDownTime != nil,
- panBaseline == nil,
- !isPinching,
- !isInspectLatched
- else { return }
- isInspectLatched = true
- tapped = nil
- UIImpactFeedbackGenerator(style: .medium).impactOccurred()
- scrubHaptic.prepare()
- if let location = lastTouchLocation {
- updateSelection(atViewportX: location.x, viewportWidth: viewportWidth)
- }
- }
- }
- private func updateSelection(atViewportX x: CGFloat, viewportWidth: CGFloat) {
- let fraction = min(max(x / viewportWidth, 0), 1)
- let date = interaction.scrollPosition.addingTimeInterval(
- interaction.visibleSeconds * TimeInterval(fraction)
- )
- selection = date
- // A featherlight tick whenever the indicator snaps to a different item.
- let captureWindow = scrubCaptureWindow(viewportWidth: viewportWidth)
- if let anchor = selectionAnchor(for: date, captureWindow: captureWindow), anchor.date != lastHapticAnchorDate {
- lastHapticAnchorDate = anchor.date
- scrubHaptic.selectionChanged()
- scrubHaptic.prepare()
- }
- }
- /// Deceleration after a flick. Mutates only scrollPosition (a transform),
- /// so each frame costs a GPU translation — same profile as live panning.
- private func startMomentum(velocitySecondsPerSecond initialVelocity: TimeInterval) {
- let visibleSeconds = interaction.visibleSeconds
- // Ignore tiny flicks.
- guard abs(initialVelocity) > visibleSeconds * 0.05 else { return }
- momentumTask?.cancel()
- momentumTask = Task { @MainActor in
- var velocity = initialVelocity
- var expected = interaction.scrollPosition
- let frameDuration: TimeInterval = 1.0 / 60.0
- while !Task.isCancelled, abs(velocity) > visibleSeconds * 0.02 {
- try? await Task.sleep(nanoseconds: UInt64(frameDuration * 1_000_000_000))
- guard !Task.isCancelled else { break }
- // Someone else (small-chart tap, data snap) moved the chart: yield.
- guard interaction.scrollPosition == expected else { break }
- let next = clampedLeadingEdge(expected.addingTimeInterval(velocity * frameDuration))
- guard next != expected else { break } // hit a domain edge
- interaction.scrollPosition = next
- expected = next
- velocity *= 0.97
- }
- // Every reassignment of momentumTask cancels the old task first, so
- // an uncancelled task is still the current one and may clear the slot;
- // a cancelled one must not (it could clobber its successor).
- if !Task.isCancelled {
- momentumTask = nil
- }
- }
- }
- /// Two-finger pinch drives the zoom continuously, anchored under the pinch
- /// centroid. Zoom changes re-lay the canvas, so commits are quantized to a
- /// geometric grid to bound the number of re-layouts per pinch.
- private func magnifyGesture(viewportWidth _: CGFloat) -> some Gesture {
- MagnifyGesture(minimumScaleDelta: 0.01)
- .onChanged { value in
- momentumTask?.cancel()
- momentumTask = nil
- if pinchAnchor == nil {
- let fraction = min(max(value.startAnchor.x, 0), 1)
- pinchAnchor = (
- visibleAtStart: interaction.visibleSeconds,
- anchorDate: interaction.scrollPosition.addingTimeInterval(
- interaction.visibleSeconds * TimeInterval(fraction)
- ),
- anchorFraction: fraction
- )
- inspectHoldTask?.cancel()
- if selection != nil { selection = nil }
- if tapped != nil { tapped = nil }
- isInspectLatched = false
- }
- guard let pinch = pinchAnchor, value.magnification > 0 else { return }
- // Live pinch previews as a transform: stretch the already-laid-out
- // canvas about the centroid. Pinch out (magnification > 1) narrows
- // the visible window, i.e. zooms in. Once the stretch drifts past
- // the commit threshold, a crisp re-layout is committed mid-gesture
- // and the transform continues from that new baseline.
- let proposed = min(
- max(
- pinch.visibleAtStart / TimeInterval(value.magnification),
- BGChartConfig.minVisibleSeconds
- ),
- BGChartConfig.maxVisibleSeconds
- )
- pinchScale = CGFloat(interaction.visibleSeconds / proposed)
- let drift = BGChartConfig.pinchCommitScaleDrift
- if pinchScale > drift || pinchScale < 1 / drift {
- commitPinchZoom(proposed)
- }
- }
- .onEnded { _ in
- guard pinchAnchor != nil else { return }
- commitPinchZoom(interaction.visibleSeconds / TimeInterval(pinchScale))
- // The commit no-ops when the zoom quantizes back to the current
- // value; the preview must still un-stretch.
- pinchScale = 1
- pinchAnchor = nil
- interaction.persistZoom()
- }
- }
- /// Quantizes to the geometric zoom grid and re-lays the canvas exactly
- /// once: window re-anchor happens in the same transaction.
- private func commitPinchZoom(_ proposed: TimeInterval) {
- guard let pinch = pinchAnchor else { return }
- let ratio = BGChartConfig.zoomStepRatio
- let base = BGChartConfig.zoomGridBaseSeconds
- let step = (log(proposed / base) / log(ratio)).rounded()
- var quantized = base * pow(ratio, step)
- quantized = min(
- max(quantized, BGChartConfig.minVisibleSeconds),
- BGChartConfig.maxVisibleSeconds
- )
- guard quantized != interaction.visibleSeconds else { return }
- interaction.visibleSeconds = quantized
- interaction.scrollPosition = clampedLeadingEdge(
- pinch.anchorDate.addingTimeInterval(-quantized * TimeInterval(pinch.anchorFraction))
- )
- updateRenderWindow(force: true)
- pinchScale = 1
- }
- /// Anchor for the live-pinch stretch: the centroid's layout position on
- /// the canvas, so the content under the fingers stays put on screen.
- private func pinchScaleAnchor(viewportWidth: CGFloat, canvasWidth: CGFloat) -> UnitPoint {
- guard let pinch = pinchAnchor, canvasWidth > 0 else { return .center }
- return UnitPoint(x: pinch.anchorFraction * viewportWidth / canvasWidth, y: 0.5)
- }
- /// Double-tap cycles the zoom presets. Snap, no animation: animating the
- /// zoom would animate canvasWidth, re-laying the canvas every frame.
- private func cycleZoomPreset() {
- let presets = BGChartConfig.zoomPresets
- let current = interaction.visibleSeconds
- let next = presets.first(where: { $0 > current + 1 }) ?? presets[0]
- momentumTask?.cancel()
- momentumTask = nil
- tapped = nil
- let trailing = interaction.scrollPosition.addingTimeInterval(current)
- interaction.visibleSeconds = next
- if interaction.followLatest {
- interaction.scrollPosition = clampedLeadingEdge(
- model.now.addingTimeInterval(-next * BGChartConfig.followNowFraction)
- )
- } else {
- interaction.scrollPosition = clampedLeadingEdge(trailing.addingTimeInterval(-next))
- }
- updateRenderWindow(force: true)
- interaction.persistZoom()
- }
- // MARK: Selection
- private struct SelectionAnchor {
- /// Where the pill/indicator is drawn — a treatment's `drawnDate`, so the
- /// indicator lands on the symbol even when decluttering moved it.
- let date: Date
- let value: Double
- /// One pill entry per item under the selector (see PillLabel).
- let texts: [String]
- }
- /// Feeds every treatment mark to `body` as (drawnDate, value, pillText).
- /// Single source for both the scrub lookup and the tap hit test.
- private func forEachTreatmentAnchor(_ body: (Date, Double, String) -> Void) {
- for group in [model.boluses, model.carbs, model.smbs, model.bgChecks,
- model.notes, model.suspends, model.resumes, model.sensorStarts]
- {
- for t in group {
- body(t.drawnDate, t.sgv, t.pillText)
- }
- }
- }
- /// Pill entry for a BG reading. Shared by the scrub lookup and the tap hit test.
- private func bgPillText(for point: BGChartModel.BGPoint) -> String {
- "BG\n\(Localizer.toDisplayUnits(String(Int(point.value))))\n\(model.pillTimeString(for: point.date))"
- }
- private func bandPillTexts(at date: Date) -> [String] {
- var texts: [String] = []
- if let band = model.overrides
- .filter({ date >= $0.start && date <= $0.end })
- .max(by: { $0.start < $1.start })
- {
- texts.append(band.pillText)
- }
- if let band = model.tempTargets
- .filter({ date >= $0.start && date <= $0.end })
- .max(by: { $0.start < $1.start })
- {
- texts.append(band.pillText)
- }
- return texts
- }
- /// Band (override / temp target) under the given date+value, if any.
- private func bandAnchor(at date: Date, value: Double) -> SelectionAnchor? {
- for band in model.overrides where date >= band.start && date <= band.end {
- if value >= band.yBottom, value <= band.yTop {
- let midY = (band.yTop + band.yBottom) / 2
- return SelectionAnchor(date: date, value: midY, texts: [band.pillText])
- }
- }
- for band in model.tempTargets where date >= band.start && date <= band.end {
- if value >= band.yBottom, value <= band.yTop {
- let midY = (band.yTop + band.yBottom) / 2
- return SelectionAnchor(date: date, value: midY, texts: [band.pillText])
- }
- }
- return nil
- }
- /// Seconds of chart time covered by `scrubCaptureRadius` at the current
- /// zoom, bounded by `scrubCaptureMaxSeconds`.
- private func scrubCaptureWindow(viewportWidth: CGFloat) -> TimeInterval {
- min(
- BGChartConfig.scrubCaptureMaxSeconds,
- TimeInterval(BGChartConfig.scrubCaptureRadius / viewportWidth) * interaction.visibleSeconds
- )
- }
- /// Scrub lookup (time-only). Collects everything under the finger instead
- /// of picking a single winner: every treatment inside the capture window
- /// joins the pill, and the nearest BG reading always does — so treatments
- /// and glucose readings can never hide one another. The indicator snaps
- /// to the nearest collected item; the pill stacks them all (treatments in
- /// drawn order, BG last).
- private func selectionAnchor(for selected: Date, captureWindow: TimeInterval) -> SelectionAnchor? {
- struct Item {
- let date: Date
- let value: Double
- let text: String
- let distance: TimeInterval
- }
- var captured: [Item] = []
- var nearestTreatment: Item?
- forEachTreatmentAnchor { date, value, text in
- let item = Item(date: date, value: value, text: text, distance: abs(date.timeIntervalSince(selected)))
- if item.distance <= captureWindow {
- captured.append(item)
- }
- if item.distance < (nearestTreatment?.distance ?? .greatestFiniteMagnitude) {
- nearestTreatment = item
- }
- }
- captured.sort { $0.date < $1.date }
- var nearestBG: Item?
- for p in model.bg {
- let d = abs(p.date.timeIntervalSince(selected))
- if d < (nearestBG?.distance ?? .greatestFiniteMagnitude) {
- nearestBG = Item(date: p.date, value: p.value, text: bgPillText(for: p), distance: d)
- }
- }
- var items = captured
- if let nearestBG, nearestBG.distance <= BGChartConfig.selectionTolerance {
- items.append(nearestBG)
- }
- if let primary = items.min(by: { $0.distance < $1.distance }) {
- let texts = items.map(\.text) + bandPillTexts(at: selected)
- return SelectionAnchor(date: primary.date, value: primary.value, texts: texts)
- }
- // Nothing under the finger. Reach for the nearest treatment (data gaps
- // leave treatments without BG neighbors), then for a band (any height)
- // at the scrub time.
- if let nearestTreatment, nearestTreatment.distance <= BGChartConfig.selectionTolerance {
- let texts = [nearestTreatment.text] + bandPillTexts(at: selected)
- return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: texts)
- }
- for band in model.overrides where selected >= band.start && selected <= band.end {
- let midY = (band.yTop + band.yBottom) / 2
- return SelectionAnchor(date: selected, value: midY, texts: [band.pillText])
- }
- for band in model.tempTargets where selected >= band.start && selected <= band.end {
- let midY = (band.yTop + band.yBottom) / 2
- return SelectionAnchor(date: selected, value: midY, texts: [band.pillText])
- }
- return nil
- }
- /// Tap hit test (screen-space, 2D). Treatments take priority, then BG
- /// points, then the override/temp-target bands under the finger.
- /// Returns nil when the tap lands on nothing — which clears the pill.
- private func tappedAnchor(at location: CGPoint, viewportWidth: CGFloat) -> SelectionAnchor? {
- let radius = BGChartConfig.tapHitRadius
- var best: SelectionAnchor?
- var bestDistance2 = radius * radius
- func consider(_ date: Date, _ value: Double, _ text: String) {
- let dx = xPosition(for: date, viewportWidth: viewportWidth) - location.x
- let dy = yPosition(forValue: value) - location.y
- let d2 = dx * dx + dy * dy
- if d2 <= bestDistance2 {
- bestDistance2 = d2
- best = SelectionAnchor(date: date, value: value, texts: [text])
- }
- }
- forEachTreatmentAnchor(consider)
- if best == nil {
- for p in model.bg {
- consider(p.date, p.value, bgPillText(for: p))
- }
- }
- if best == nil {
- let date = interaction.scrollPosition.addingTimeInterval(
- interaction.visibleSeconds * TimeInterval(location.x / viewportWidth)
- )
- return bandAnchor(at: date, value: value(atY: location.y))
- }
- if let best {
- let texts = best.texts + bandPillTexts(at: best.date)
- return SelectionAnchor(date: best.date, value: best.value, texts: texts)
- }
- return nil
- }
- private func handleTap(at location: CGPoint, viewportWidth: CGFloat) {
- guard plotFrame.height > 0 else { return }
- tapped = tappedAnchor(at: location, viewportWidth: viewportWidth)
- }
- /// The anchor the overlay should show: a live scrub wins over a sticky tap.
- private func activeAnchor(viewportWidth: CGFloat) -> SelectionAnchor? {
- if isInspectLatched, let selected = selection {
- return selectionAnchor(for: selected, captureWindow: scrubCaptureWindow(viewportWidth: viewportWidth))
- }
- return tapped
- }
- // MARK: Value-to-pixel maps (shared by the selection overlay and tap hit test)
- private func xPosition(for date: Date, viewportWidth: CGFloat) -> CGFloat {
- CGFloat(
- date.timeIntervalSince(interaction.scrollPosition) / interaction.visibleSeconds
- ) * viewportWidth
- }
- private func yPosition(forValue value: Double) -> CGFloat {
- let yMax = chartYDomainUpperBound(model.maxBG)
- let clamped = min(max(value, 0), yMax)
- return plotFrame.minY + CGFloat(1 - clamped / yMax) * plotFrame.height
- }
- private func value(atY y: CGFloat) -> Double {
- guard plotFrame.height > 0 else { return 0 }
- let yMax = chartYDomainUpperBound(model.maxBG)
- let fraction = 1 - (y - plotFrame.minY) / plotFrame.height
- return Double(min(max(fraction, 0), 1)) * yMax
- }
- @ViewBuilder
- private func overrideBandLabelsOverlay(viewportWidth: CGFloat) -> some View {
- if plotFrame.height > 0 {
- let visibleStart = interaction.scrollPosition
- let visibleEnd = interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds)
- ForEach(model.overrides.filter { $0.end >= visibleStart && $0.start <= visibleEnd }) { band in
- let visibleBandStart = max(band.start, visibleStart)
- let visibleBandEnd = min(band.end, visibleEnd)
- let xStart = xPosition(for: visibleBandStart, viewportWidth: viewportWidth)
- let xEnd = xPosition(for: visibleBandEnd, viewportWidth: viewportWidth)
- let bandWidth = max(0, xEnd - xStart)
- let y = yPosition(forValue: (band.yBottom + band.yTop) / 2)
- if bandWidth > 24 {
- Text(band.label)
- .font(.caption2)
- .foregroundStyle(.white)
- .lineLimit(1)
- .truncationMode(.tail)
- .padding(.trailing, 10)
- .frame(width: max(0, bandWidth - 6), alignment: .trailing)
- .clipped()
- .position(x: xStart + bandWidth / 2, y: y)
- }
- }
- }
- }
- /// Vertical indicator + pill for the current selection, rendered in the
- /// shell with the same linear maps the canvas uses — neither scrubbing
- /// nor a tapped pill ever re-lays the canvas.
- ///
- /// The pill wraps long texts (notes) at its max width; placement uses the
- /// measured pill size so the pill always sits fully on screen, below the
- /// anchor when there is room and above it otherwise. Wrapping relies on
- /// SwiftUI's word wrapping, which respects the actual font metrics, so
- /// there is no manual line splitting.
- @ViewBuilder
- private func selectionOverlay(viewportWidth: CGFloat) -> some View {
- if plotFrame.height > 0, let anchor = activeAnchor(viewportWidth: viewportWidth) {
- let x = xPosition(for: anchor.date, viewportWidth: viewportWidth)
- if x >= 0, x <= viewportWidth {
- let y = yPosition(forValue: anchor.value)
- Rectangle()
- .fill(Color.primary.opacity(0.5))
- .frame(width: 1, height: plotFrame.height)
- .position(x: x, y: plotFrame.midY)
- // Measured size lags the text by one frame; fall back to a
- // small nominal size until the first measurement lands.
- let pillW = max(pillSize.width, 60)
- let pillH = max(pillSize.height, 28)
- let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4)
- let below = y + 14 + pillH / 2
- let above = y - 14 - pillH / 2
- let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4
- let labelY = fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4)
- PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16))
- .position(x: labelX, y: labelY)
- }
- }
- }
- }
- // MARK: - Small chart (overview + tap/drag-to-navigate)
- private struct SmallBGChart: View {
- @ObservedObject var model: BGChartModel
- @ObservedObject var interaction: BGChartInteraction
- /// True once the touch has travelled far enough to count as a drag; from
- /// then on the main chart tracks the finger live instead of waiting for release.
- @State private var isScrubbing = false
- var body: some View {
- GeometryReader { geo in
- let width = max(geo.size.width, 1)
- let domainSeconds = max(model.domainEnd.timeIntervalSince(model.domainStart), 1)
- ZStack(alignment: .topLeading) {
- BGChartCanvas(
- model: model,
- generation: model.generation,
- isSmall: true,
- windowStart: model.domainStart,
- windowEnd: model.domainStart.addingTimeInterval(domainSeconds),
- canvasWidth: width,
- height: geo.size.height,
- visibleSeconds: domainSeconds,
- timeZone: .current
- )
- .equatable()
- viewportBox(width: width, height: geo.size.height, domainSeconds: domainSeconds)
- .allowsHitTesting(false)
- }
- .contentShape(Rectangle())
- .gesture(
- DragGesture(minimumDistance: 0)
- .onChanged { value in
- let distance = hypot(value.translation.width, value.translation.height)
- if isScrubbing || distance >= BGChartConfig.inspectMovementTolerance {
- isScrubbing = true
- navigate(toLocalX: value.location.x, width: width, domainSeconds: domainSeconds, animated: false)
- }
- }
- .onEnded { value in
- navigate(toLocalX: value.location.x, width: width, domainSeconds: domainSeconds, animated: !isScrubbing)
- isScrubbing = false
- }
- )
- }
- .background(Color(.systemBackground))
- }
- /// Box showing where the main chart's visible window currently sits.
- private func viewportBox(width: CGFloat, height: CGFloat, domainSeconds: TimeInterval) -> some View {
- let startFraction = interaction.scrollPosition.timeIntervalSince(model.domainStart) / domainSeconds
- let boxWidth = min(max(2, CGFloat(interaction.visibleSeconds / domainSeconds) * width), width)
- let boxLeft = min(max(CGFloat(startFraction) * width, 0), width - boxWidth)
- return Rectangle()
- .fill(Color.primary.opacity(0.08))
- .overlay(Rectangle().stroke(Color.primary.opacity(0.35), lineWidth: 1))
- .frame(width: boxWidth, height: height)
- .offset(x: boxLeft)
- }
- /// Maps a touch x to a time and recenters the main chart on it (clamped to
- /// the data window). Following re-arms automatically via the main chart's
- /// scroll observer if the user lands back at the latest data.
- private func navigate(toLocalX x: CGFloat, width: CGFloat, domainSeconds: TimeInterval, animated: Bool) {
- let fraction = min(max(x / width, 0), 1)
- let date = model.domainStart.addingTimeInterval(domainSeconds * TimeInterval(fraction))
- let length = interaction.visibleSeconds
- var target = date.addingTimeInterval(-length / 2)
- let minStart = model.domainStart
- let maxStart = model.domainEnd.addingTimeInterval(-length)
- if maxStart > minStart {
- target = min(max(target, minStart), maxStart)
- }
- // Same rule as the main chart's scrollToNow: only nearby targets (still
- // inside the render window) animate cleanly; long jumps snap. Live
- // drag-tracking never animates — each move must land within the frame.
- if animated, abs(target.timeIntervalSince(interaction.scrollPosition)) <= interaction.visibleSeconds {
- withAnimation(.easeInOut(duration: 0.3)) {
- interaction.scrollPosition = target
- }
- } else {
- interaction.scrollPosition = target
- }
- }
- }
- // MARK: - Canvas (laid out once per data / zoom change; translated while panning)
- /// The chart content, laid out over the render window at fixed width. Stored
- /// properties deliberately exclude the pan position, and the explicit
- /// Equatable conformance below compares only cheap metadata (`generation`
- /// stands in for the data arrays) — so SwiftUI provably skips this body
- /// during panning and momentum.
- private struct BGChartCanvas: View, Equatable {
- let model: BGChartModel // plain reference on purpose: updates arrive via `generation`
- let generation: Int
- let isSmall: Bool
- let windowStart: Date
- let windowEnd: Date
- let canvasWidth: CGFloat
- let height: CGFloat
- let visibleSeconds: TimeInterval
- let timeZone: TimeZone
- static func == (lhs: BGChartCanvas, rhs: BGChartCanvas) -> Bool {
- lhs.generation == rhs.generation &&
- lhs.isSmall == rhs.isSmall &&
- lhs.windowStart == rhs.windowStart &&
- lhs.windowEnd == rhs.windowEnd &&
- lhs.canvasWidth == rhs.canvasWidth &&
- lhs.height == rhs.height &&
- lhs.visibleSeconds == rhs.visibleSeconds &&
- lhs.timeZone == rhs.timeZone
- }
- private var basalScale: Double {
- guard model.maxBasal > 0 else { return 0 }
- return model.maxBG / model.maxBasal
- }
- var body: some View {
- let showTreatments = !isSmall || model.smallGraphTreatments
- let chart = Chart {
- if showTreatments {
- bgBandMarks
- basalMarks
- scheduledBasalMarks
- }
- coneMarks
- if !isSmall {
- yesterdayMarks
- }
- bgLineMarks
- bgPointsMark
- predictionLineMark
- predictionVariantMarks
- if showTreatments {
- treatmentMarks
- }
- if !isSmall {
- ruleMarks
- } else if model.showMidnight {
- midnightRuleMarks
- }
- }
- .chartXScale(domain: windowStart ... windowEnd)
- .chartYScale(domain: 0 ... chartYDomainUpperBound(model.maxBG))
- .chartLegend(.hidden)
- .chartYAxis(.hidden)
- return Group {
- if isSmall {
- chart.chartXAxis(.hidden)
- } else {
- chart.chartXAxis {
- AxisMarks(values: xAxisMarkDates()) { value in
- AxisValueLabel {
- if let date = value.as(Date.self) {
- xAxisLabel(for: date)
- }
- }
- }
- }
- }
- }
- .frame(width: canvasWidth, height: height)
- }
- // MARK: Windowing
- // The mark series sliced to the render window: marks outside it clip
- // invisibly but still cost layout, so an unfiltered re-layout does several
- // times the work for nothing.
- private func windowed<T>(_ items: [T], date: (T) -> Date) -> [T] {
- items.filter { item in
- let d = date(item)
- return d >= windowStart && d <= windowEnd
- }
- }
- /// For line/area series: like `windowed`, but keeps one point beyond each
- /// edge so segments crossing (or spanning) the window survive. Assumes
- /// ascending dates.
- private func windowedLine<T>(_ items: [T], date: (T) -> Date) -> [T] {
- guard items.count > 1 else { return items }
- let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count
- let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1
- let lo = max(firstInside - 1, 0)
- let hi = min(firstBeyond, items.count - 1)
- guard lo <= hi else { return [] }
- return Array(items[lo ... hi])
- }
- // MARK: X axis
- /// Grid/label stride ladder for the current zoom level.
- private var xAxisStrideSeconds: TimeInterval {
- let visibleHours = visibleSeconds / 3600
- if visibleHours <= 2 { return 1800 }
- if visibleHours <= 6 { return 3600 }
- if visibleHours <= 12 { return 2 * 3600 }
- return 4 * 3600
- }
- /// Axis mark dates anchored to local midnight (in the configured graph
- /// time zone), unlike `.stride(by: .hour)`, which anchors to the domain start.
- private func xAxisMarkDates() -> [Date] {
- var cal = Calendar(identifier: .gregorian)
- cal.timeZone = timeZone
- let stride = xAxisStrideSeconds
- var mark = cal.startOfDay(for: windowStart)
- var marks: [Date] = []
- while mark <= windowEnd {
- if mark >= windowStart {
- marks.append(mark)
- }
- mark = mark.addingTimeInterval(stride)
- }
- return marks
- }
- /// Midnight ticks carry the day ("Tue 7") so panned-back history stays
- /// unambiguous; other ticks show the hour (plus minutes at sub-hour strides).
- private func xAxisLabel(for date: Date) -> some View {
- var cal = Calendar(identifier: .gregorian)
- cal.timeZone = timeZone
- let comps = cal.dateComponents([.hour, .minute], from: date)
- return Group {
- if comps.hour == 0, comps.minute == 0 {
- Text(date, format: .dateTime.weekday(.abbreviated).day())
- } else if xAxisStrideSeconds < 3600 {
- Text(date, format: .dateTime.hour().minute())
- } else {
- Text(date, format: .dateTime.hour())
- }
- }
- .font(.footnote)
- .environment(\.timeZone, timeZone)
- }
- // MARK: Marks
- @ChartContentBuilder
- private var bgBandMarks: some ChartContent {
- ForEach(model.overrides.filter { $0.end >= windowStart && $0.start <= windowEnd }) { band in
- RectangleMark(
- xStart: .value("start", band.start),
- xEnd: .value("end", band.end),
- yStart: .value("yBottom", band.yBottom),
- yEnd: .value("yTop", band.yTop)
- )
- .foregroundStyle(model.overrideColor.opacity(0.6))
- }
- ForEach(model.tempTargets.filter { $0.end >= windowStart && $0.start <= windowEnd }) { band in
- RectangleMark(
- xStart: .value("start", band.start),
- xEnd: .value("end", band.end),
- yStart: .value("yBottom", band.yBottom),
- yEnd: .value("yTop", band.yTop)
- )
- .foregroundStyle(model.tempTargetColor.opacity(0.5))
- }
- }
- @ChartContentBuilder
- private var basalMarks: some ChartContent {
- ForEach(model.basal.filter { $0.end >= windowStart && $0.start <= windowEnd }) { step in
- RectangleMark(
- xStart: .value("start", step.start),
- xEnd: .value("end", step.end),
- yStart: .value("yBottom", 0.0),
- yEnd: .value("rate", step.rate * basalScale)
- )
- .foregroundStyle(.blue.opacity(0.35))
- }
- }
- @ChartContentBuilder
- private var scheduledBasalMarks: some ChartContent {
- ForEach(windowedLine(model.basalScheduled) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("rate", pt.rate * basalScale),
- series: .value("series", "basalScheduled")
- )
- .lineStyle(StrokeStyle(lineWidth: 2, dash: [10, 5]))
- .foregroundStyle(Color.blue.opacity(0.8))
- }
- }
- @ChartContentBuilder
- private var coneMarks: some ChartContent {
- ForEach(windowedLine(model.cone) { $0.date }) { pt in
- AreaMark(
- x: .value("time", pt.date),
- yStart: .value("yMin", pt.yMin),
- yEnd: .value("yMax", pt.yMax)
- )
- // Same fill as Trio's cone.
- .foregroundStyle(Color(.systemBlue).opacity(0.4))
- .interpolationMethod(.monotone)
- }
- }
- @ChartContentBuilder
- private var yesterdayMarks: some ChartContent {
- ForEach(windowedLine(model.yesterday) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "yesterday")
- )
- .foregroundStyle(Color(.systemGray).opacity(0.4))
- .lineStyle(StrokeStyle(lineWidth: 1.5))
- .interpolationMethod(.linear)
- }
- }
- @ChartContentBuilder
- private var bgLineMarks: some ChartContent {
- if model.showLines {
- ForEach(model.bgRuns) { run in
- if let first = run.points.first, let last = run.points.last,
- last.date >= windowStart, first.date <= windowEnd
- {
- ForEach(windowedLine(run.points) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "bg-\(run.id)")
- )
- .foregroundStyle(run.color)
- .lineStyle(StrokeStyle(lineWidth: 2.5))
- .interpolationMethod(.linear)
- }
- }
- }
- }
- }
- @ChartContentBuilder
- private var bgPointsMark: some ChartContent {
- if model.showDots {
- ForEach(windowed(model.bg) { $0.date }) { pt in
- PointMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value)
- )
- .symbolSize(isSmall ? 14 : 30)
- .foregroundStyle(pt.color)
- }
- }
- }
- @ChartContentBuilder
- private var predictionLineMark: some ChartContent {
- ForEach(windowedLine(model.prediction) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "prediction")
- )
- .foregroundStyle(.purple)
- .lineStyle(StrokeStyle(lineWidth: 2))
- }
- }
- @ChartContentBuilder
- private var predictionVariantMarks: some ChartContent {
- ForEach(windowedLine(model.ztPrediction) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "zt")
- )
- .foregroundStyle(Color(.systemGray))
- .lineStyle(StrokeStyle(lineWidth: 2))
- }
- ForEach(windowedLine(model.iobPrediction) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "iob")
- )
- .foregroundStyle(Color(.systemBlue))
- .lineStyle(StrokeStyle(lineWidth: 2))
- }
- ForEach(windowedLine(model.cobPrediction) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "cob")
- )
- .foregroundStyle(Color(.systemOrange))
- .lineStyle(StrokeStyle(lineWidth: 2))
- }
- ForEach(windowedLine(model.uamPrediction) { $0.date }) { pt in
- LineMark(
- x: .value("time", pt.date),
- y: .value("bg", pt.value),
- series: .value("series", "uam")
- )
- .foregroundStyle(Color(.systemPink))
- .lineStyle(StrokeStyle(lineWidth: 2))
- }
- }
- @ChartContentBuilder
- private var treatmentMarks: some ChartContent {
- ForEach(windowed(model.boluses) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbolSize(isSmall ? 24 : 64)
- .foregroundStyle(Color.blue.opacity(0.75))
- .annotation(position: .top, alignment: .center) {
- if !isSmall, Storage.shared.showValues.value {
- Text(pt.label).font(.caption2).foregroundColor(.primary)
- }
- }
- }
- ForEach(windowed(model.carbs) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbolSize(isSmall ? 24 : 64)
- .foregroundStyle(Color.orange.opacity(0.75))
- .annotation(position: .top, alignment: .center) {
- if !isSmall, Storage.shared.showValues.value {
- Text(pt.label).font(.caption2).foregroundColor(.primary)
- }
- }
- }
- ForEach(windowed(model.smbs) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbol(DownwardTriangle())
- .symbolSize(isSmall ? 28 : 64)
- .foregroundStyle(Color.blue.opacity(0.75))
- .annotation(position: .top, alignment: .center) {
- if !isSmall, Storage.shared.showValues.value {
- Text(pt.label).font(.caption2).foregroundColor(.primary)
- }
- }
- }
- ForEach(windowed(model.bgChecks) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbolSize(isSmall ? 22 : 54)
- .foregroundStyle(Color.red.opacity(0.75))
- }
- ForEach(windowed(model.suspends) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbol(.square)
- .symbolSize(isSmall ? 22 : 54)
- .foregroundStyle(Color.teal.opacity(0.75))
- }
- ForEach(windowed(model.resumes) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbol(.square)
- .symbolSize(isSmall ? 22 : 54)
- .foregroundStyle(Color.teal.opacity(0.5))
- }
- ForEach(windowed(model.sensorStarts) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbol(.cross)
- .symbolSize(isSmall ? 22 : 54)
- .foregroundStyle(Color.indigo.opacity(0.75))
- }
- ForEach(windowed(model.notes) { $0.drawnDate }) { pt in
- PointMark(
- x: .value("time", pt.drawnDate),
- y: .value("sgv", pt.sgv)
- )
- .symbolSize(isSmall ? 22 : 54)
- .foregroundStyle(Color.gray.opacity(0.75))
- }
- }
- @ChartContentBuilder
- private var ruleMarks: some ChartContent {
- RuleMark(y: .value("low", model.lowLine))
- .lineStyle(StrokeStyle(lineWidth: 2))
- .foregroundStyle(Color.red.opacity(0.5))
- RuleMark(y: .value("high", model.highLine))
- .lineStyle(StrokeStyle(lineWidth: 2))
- .foregroundStyle(Color.yellow.opacity(0.5))
- if model.now >= windowStart, model.now <= windowEnd {
- RuleMark(x: .value("now", model.now))
- .lineStyle(StrokeStyle(lineWidth: 1))
- .foregroundStyle(Color.gray.opacity(0.6))
- }
- if model.show30Min, let d = model.thirtyMinMark, d >= windowStart, d <= windowEnd {
- RuleMark(x: .value("30m", d))
- .lineStyle(StrokeStyle(lineWidth: 0.5))
- .foregroundStyle(Color.blue.opacity(0.7))
- }
- if model.show90Min, let d = model.ninetyMinMark, d >= windowStart, d <= windowEnd {
- RuleMark(x: .value("90m", d))
- .lineStyle(StrokeStyle(lineWidth: 0.5))
- .foregroundStyle(Color.orange.opacity(0.7))
- }
- if model.showDIA {
- ForEach(model.diaMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in
- RuleMark(x: .value("dia", d))
- .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2]))
- .foregroundStyle(Color.gray.opacity(0.5))
- }
- }
- if model.showMidnight {
- midnightRuleMarks
- }
- }
- @ChartContentBuilder
- private var midnightRuleMarks: some ChartContent {
- ForEach(model.midnightMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in
- RuleMark(x: .value("midnight", d))
- .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [5, 3]))
- .foregroundStyle(Color.teal.opacity(0.6))
- }
- }
- }
- // MARK: - Pinned y-axis overlay
- /// Renders only the y-axes — BG values trailing, basal rates leading — at a
- /// fixed position over the scrolling canvas, plus a phantom x-axis label that
- /// reserves exactly the height of the canvas's hour labels so both plots end
- /// at the same y. Also reports its plot frame so the shell's selection
- /// overlay shares the same value-to-pixel mapping.
- private struct StaticYAxisOverlay: View, Equatable {
- let generation: Int
- let maxBG: Double
- let maxBasal: Double
- private var basalScale: Double {
- guard maxBasal > 0 else { return 0 }
- return maxBG / maxBasal
- }
- private var leadingBasalTicks: [Double] {
- guard basalScale > 0 else { return [] }
- let ticks = stride(from: 0.0, through: maxBasal, by: max(0.5, maxBasal / 4))
- .map { $0 * basalScale }
- return Array(ticks)
- }
- var body: some View {
- Chart {
- // Invisible content at the domain corners so the scales resolve
- // and the plot (and with it the axes) materializes.
- PointMark(x: .value("edge", 0.0), y: .value("min", 0.0))
- .opacity(0)
- PointMark(x: .value("edge", 1.0), y: .value("max", maxBG))
- .opacity(0)
- }
- .chartXScale(domain: 0 ... 1)
- .chartYScale(domain: 0 ... chartYDomainUpperBound(maxBG))
- .chartLegend(.hidden)
- .chartXAxis {
- AxisMarks(values: [0.5]) { _ in
- AxisValueLabel {
- Text("00").font(.footnote).foregroundStyle(.clear)
- }
- }
- }
- .chartYAxis {
- AxisMarks(position: .trailing) { value in
- AxisTick()
- AxisValueLabel {
- if let v = value.as(Double.self) {
- Text(Localizer.toDisplayUnits(String(v)))
- }
- }
- }
- AxisMarks(position: .leading, values: leadingBasalTicks) { value in
- AxisTick()
- AxisValueLabel {
- if let v = value.as(Double.self) {
- let rate = basalScale > 0 ? v / basalScale : 0
- Text(String(format: "%.1fU", rate))
- }
- }
- }
- }
- .chartOverlay { proxy in
- GeometryReader { geo in
- Color.clear.preference(
- key: PlotFramePreferenceKey.self,
- value: proxy.plotFrame.map { geo[$0] } ?? .zero
- )
- }
- }
- }
- }
- private struct PlotFramePreferenceKey: PreferenceKey {
- static var defaultValue: CGRect = .zero
- static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
- value = nextValue()
- }
- }
- /// Rendered size of the visible selection pill, reported so the shell can
- /// clamp its position using the real (wrapped) dimensions.
- private struct PillSizePreferenceKey: PreferenceKey {
- static var defaultValue: CGSize = .zero
- static func reduce(value: inout CGSize, nextValue: () -> CGSize) {
- value = nextValue()
- }
- }
- // MARK: - Shared pieces
- private struct DownwardTriangle: ChartSymbolShape {
- var perceptualUnitRect: CGRect { CGRect(x: 0, y: 0, width: 1, height: 1) }
- func path(in rect: CGRect) -> Path {
- var p = Path()
- p.move(to: CGPoint(x: rect.minX, y: rect.minY))
- p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
- p.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
- p.closeSubpath()
- return p
- }
- }
- private struct PillLabel: View {
- /// One entry per selected item. A lone entry keeps its multi-line layout;
- /// several stack as compact one-line-per-item rows so the pill stays
- /// readable over a busy cluster.
- let texts: [String]
- let maxWidth: CGFloat
- var body: some View {
- content
- .padding(.horizontal, 6)
- .padding(.vertical, 3)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(Color(.secondarySystemBackground))
- .overlay(
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color.primary, lineWidth: 0.5)
- )
- )
- .background(
- GeometryReader { geo in
- Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size)
- }
- )
- // Transparent flexible container: it caps the width the text can
- // wrap to, while the visible pill above still hugs its content.
- .frame(maxWidth: maxWidth)
- }
- @ViewBuilder
- private var content: some View {
- if texts.count == 1 {
- // Bound pathological texts; a note this long is better read in
- // Nightscout than on a chart pill.
- entry(texts[0], lineLimit: 10)
- } else {
- VStack(spacing: 3) {
- ForEach(texts.indices, id: \.self) { index in
- if index > 0 {
- // Fixed-width hairline: a Divider would greedily
- // stretch the hugging pill to its max width.
- Rectangle()
- .fill(Color.primary.opacity(0.25))
- .frame(width: 46, height: 0.5)
- }
- // Stacked items collapse to "Bolus 2.5U 14:32" rows.
- entry(texts[index].replacingOccurrences(of: "\n", with: " "), lineLimit: 4)
- }
- }
- }
- }
- private func entry(_ text: String, lineLimit: Int) -> some View {
- Text(text)
- .font(.caption2)
- .foregroundColor(.primary)
- .multilineTextAlignment(.center)
- .lineLimit(lineLimit)
- }
- }
|