@@ -29,6 +29,7 @@ struct HeartChartView: View {
2929 @State private var displayedDate : Date = Date ( )
3030 @State private var displayedMin : Int = 0
3131 @State private var displayedMax : Int = 0
32+ @State private var scrollPositionDate : Date = Date ( )
3233
3334 var windowStart : Date {
3435 Calendar . current. startOfDay ( for: Calendar . current. date ( byAdding: . day, value: dayOffset, to: Date ( ) ) !)
@@ -40,6 +41,17 @@ struct HeartChartView: View {
4041 points. filter { $0. date >= windowStart && $0. date <= windowEnd }
4142 }
4243
44+ var visiblePoints : [ HeartChartDataPoint ] {
45+ let visibleEnd = Date ( timeInterval: 86400 , since: scrollPositionDate)
46+ return points. filter { $0. date >= scrollPositionDate && $0. date <= visibleEnd }
47+ }
48+ var visibleMin : Int {
49+ Int ( visiblePoints. map ( { $0. min } ) . min ( ) ?? 0 )
50+ }
51+ var visibleMax : Int {
52+ Int ( visiblePoints. map ( { $0. max } ) . max ( ) ?? 0 )
53+ }
54+
4355 func heartPoints( ) -> [ HeartChartDataPoint ] {
4456 let raw = ChartManager . shared. heartPoints ( )
4557
@@ -51,10 +63,16 @@ struct HeartChartView: View {
5163 return grouped. map { ( bucket, samples) in
5264 let values = samples. map { $0. value }
5365 return HeartChartDataPoint (
54- date: bucket,
66+ date: Calendar . current . date ( byAdding : . minute , value : 30 , to : bucket ) ?? bucket,
5567 min: values. min ( ) ?? 0 ,
5668 max: values. max ( ) ?? 0 ,
57- average: values. reduce ( 0 , + ) / Double( values. count) ,
69+ average: {
70+ let sorted = values. sorted ( )
71+ let mid = sorted. count / 2
72+ return sorted. count % 2 == 0
73+ ? ( sorted [ mid - 1 ] + sorted[ mid] ) / 2
74+ : sorted [ mid]
75+ } ( ) ,
5876 values: values
5977 )
6078 } . sorted { $0. date < $1. date }
@@ -110,6 +128,94 @@ struct HeartChartView: View {
110128 displayedMax = Int ( windowPoints. map ( { $0. max } ) . max ( ) ?? 0 )
111129 }
112130
131+ func chartPage( for offset: Int ) -> some View {
132+ let start = Calendar . current. startOfDay ( for: Calendar . current. date ( byAdding: . day, value: offset, to: Date ( ) ) !)
133+ let end = Date ( timeInterval: 86400 , since: start)
134+ let pagePoints = points. filter { $0. date >= start && $0. date <= end }
135+ let pageMin = Int ( pagePoints. map ( { $0. min } ) . min ( ) ?? 0 )
136+ let pageMax = Int ( pagePoints. map ( { $0. max } ) . max ( ) ?? 0 )
137+
138+ return Chart {
139+ ForEach ( pagePoints) { point in
140+ chartContent ( for: point)
141+ }
142+ }
143+ . frame ( height: 280 )
144+ . padding ( . horizontal, 8 )
145+ . chartYScale ( domain: ( pageMin - 20 ) ... ( pageMax + 20 ) )
146+ . chartXScale ( domain: start... end)
147+ . chartXAxis {
148+ AxisMarks ( values: . stride( by: . hour, count: 6 ) ) { value in
149+ AxisGridLine ( stroke: StrokeStyle ( lineWidth: 0.5 , dash: [ 4 ] ) )
150+ AxisValueLabel ( format: . dateTime. hour ( . defaultDigits( amPM: . omitted) ) )
151+ }
152+ }
153+ . chartYAxis {
154+ AxisMarks ( position: . trailing) { value in
155+ AxisGridLine ( )
156+ AxisValueLabel ( )
157+ }
158+ }
159+ }
160+
161+ var pagedChart : some View {
162+ VStack ( spacing: 0 ) {
163+ HStack {
164+ Button {
165+ dayOffset -= 1
166+ } label: {
167+ Image ( systemName: " chevron.left " )
168+ }
169+ . disabled ( Calendar . current. isDate ( windowStart, inSameDayAs: earliestDate) )
170+
171+ Spacer ( )
172+
173+ Button {
174+ dayOffset += 1
175+ } label: {
176+ Image ( systemName: " chevron.right " )
177+ }
178+ . disabled ( dayOffset >= 0 )
179+ }
180+ . padding ( . horizontal, 12 )
181+ . padding ( . vertical, 8 )
182+ . background ( Color ( . secondarySystemGroupedBackground) )
183+ . clipShape ( Capsule ( ) )
184+ . padding ( . bottom, 8 )
185+
186+ chartPage ( for: dayOffset)
187+ }
188+ }
189+
190+ @available ( iOS 17 , * )
191+ var scrollableChart : some View {
192+ Chart {
193+ ForEach ( points) { point in
194+ chartContent ( for: point)
195+ }
196+ }
197+ . frame ( height: 280 )
198+ . padding ( . horizontal, 8 )
199+ . chartYScale ( domain: ( displayedMin - 20 ) ... ( displayedMax + 20 ) )
200+ . chartXAxis {
201+ AxisMarks ( values: . stride( by: . hour, count: 6 ) ) { value in
202+ AxisGridLine ( stroke: StrokeStyle ( lineWidth: 0.5 , dash: [ 4 ] ) )
203+ AxisValueLabel ( format: . dateTime. hour ( . defaultDigits( amPM: . omitted) ) )
204+ }
205+ }
206+ . chartYAxis {
207+ AxisMarks ( position: . trailing) { value in
208+ AxisGridLine ( )
209+ AxisValueLabel ( )
210+ }
211+ }
212+ . chartScrollableAxes ( . horizontal)
213+ . chartXVisibleDomain ( length: 86400 )
214+ . chartXScale ( domain: ( earliestDate - 1800 ) ... ( latestDate + 1800 ) )
215+ . chartScrollPosition ( x: $scrollPositionDate)
216+ . chartScrollTargetBehavior ( . valueAligned( unit: 3600 ) )
217+ }
218+
113219 var body : some View {
114220 Group {
115221 Group {
@@ -118,54 +224,10 @@ struct HeartChartView: View {
118224 } else {
119225 Section {
120226 VStack ( spacing: 0 ) {
121- HStack {
122- Button {
123- dayOffset -= 1
124- } label: {
125- Image ( systemName: " chevron.left " )
126- }
127- . disabled ( Calendar . current. isDate ( windowStart, inSameDayAs: earliestDate) )
128-
129- Spacer ( )
130-
131- Text ( displayedDate. formatted ( . dateTime. weekday ( . abbreviated) . month ( . abbreviated) . day ( ) . year ( ) ) )
132- . foregroundColor ( . primary)
133-
134- Spacer ( )
135-
136- Button {
137- dayOffset += 1
138- } label: {
139- Image ( systemName: " chevron.right " )
140- }
141- . disabled ( dayOffset >= 0 )
142- }
143- . padding ( . horizontal, 12 )
144- . padding ( . vertical, 8 )
145- . background ( Color ( . secondarySystemGroupedBackground) )
146- . clipShape ( Capsule ( ) )
147- . padding ( . bottom, 8 )
148-
149- Chart {
150- ForEach ( windowPoints) { point in
151- chartContent ( for: point)
152- }
153- }
154- . frame ( height: 280 )
155- . padding ( . horizontal, 8 )
156- . chartYScale ( domain: ( displayedMin - 20 ) ... ( displayedMax + 20 ) )
157- . chartXScale ( domain: windowStart... windowEnd)
158- . chartXAxis {
159- AxisMarks ( values: . stride( by: . hour, count: 6 ) ) { value in
160- AxisGridLine ( stroke: StrokeStyle ( lineWidth: 0.5 , dash: [ 4 ] ) )
161- AxisValueLabel ( format: . dateTime. hour ( . defaultDigits( amPM: . omitted) ) )
162- }
163- }
164- . chartYAxis {
165- AxisMarks ( position: . trailing) { value in
166- AxisGridLine ( )
167- AxisValueLabel ( )
168- }
227+ if #available( iOS 17 , * ) {
228+ scrollableChart
229+ } else {
230+ pagedChart
169231 }
170232 }
171233 . buttonStyle ( . plain)
@@ -178,6 +240,9 @@ struct HeartChartView: View {
178240 . font ( . system( . title, design: . rounded) )
179241 . foregroundColor ( . primary)
180242 + Text( " BPM " )
243+ Text ( displayedDate. formatted ( . dateTime. weekday ( . abbreviated) . month ( . abbreviated) . day ( ) . year ( ) ) )
244+ . foregroundColor ( . secondary)
245+ . font ( . subheadline)
181246 }
182247 . fontWeight ( . semibold)
183248 }
@@ -193,20 +258,31 @@ struct HeartChartView: View {
193258 }
194259 . onAppear {
195260 points = heartPoints ( )
261+ scrollPositionDate = windowStart
196262 updateDisplayed ( )
197263 }
198264 . onChange ( of: dayOffset) { _ in
199265 updateDisplayed ( )
200266 }
267+ . onChange ( of: scrollPositionDate) { newValue in
268+ displayedDate = newValue
269+ }
270+ . onChange ( of: scrollPositionDate) { newValue in
271+ Task {
272+ try ? await Task . sleep ( nanoseconds: 300_000_000 )
273+ if scrollPositionDate == newValue {
274+ let clamped = min ( max ( newValue, Calendar . current. startOfDay ( for: earliestDate) ) , Calendar . current. startOfDay ( for: latestDate) )
275+ if clamped != newValue {
276+ scrollPositionDate = clamped
277+ }
278+ displayedMin = visibleMin
279+ displayedMax = visibleMax
280+ }
281+ }
282+ }
201283 . onChange ( of: bleManager. heartRate) { _ in
202284 points = heartPoints ( )
203285 updateDisplayed ( )
204286 }
205287 }
206288}
207-
208- #Preview {
209- List {
210- HeartChartView ( )
211- }
212- }
0 commit comments