@@ -23,6 +23,12 @@ struct ActivityChartView: View {
2323 ActivityChartMode ( rawValue: modeRaw) ?? . hourly
2424 }
2525
26+ // MARK: - Hover selection state
27+
28+ @State private var selectedDailyId : String ?
29+ @State private var selectedHourlyOffset : Int ?
30+ @State private var selectedMonthlyId : String ?
31+
2632 // MARK: - Cached data transforms
2733
2834 /// Cached chart data — recomputed only when source data or mode changes.
@@ -138,7 +144,12 @@ struct ActivityChartView: View {
138144 . padding ( . vertical, 8 )
139145 . onAppear { refreshCachedData ( ) }
140146 . onChange ( of: dataFingerprint) { _ in refreshCachedData ( ) }
141- . onChange ( of: modeRaw) { _ in ensureCachedData ( for: mode) }
147+ . onChange ( of: modeRaw) { _ in
148+ selectedDailyId = nil
149+ selectedHourlyOffset = nil
150+ selectedMonthlyId = nil
151+ ensureCachedData ( for: mode)
152+ }
142153 }
143154
144155 // MARK: - Shared chart styling
@@ -172,34 +183,46 @@ struct ActivityChartView: View {
172183 return " 7-day activity chart. \( total) messages this week "
173184 } ( )
174185
175- return Chart ( data) { point in
176- AreaMark (
177- x: . value( " Day " , point. date, unit: . day) ,
178- y: . value( " Messages " , point. count)
179- )
180- . foregroundStyle (
181- . linearGradient(
182- colors: [ ThemeColors . chartAccent. opacity ( 0.3 ) , ThemeColors . chartAccent. opacity ( 0.1 ) ] ,
183- startPoint: . top,
184- endPoint: . bottom
186+ return Chart {
187+ ForEach ( data) { point in
188+ AreaMark (
189+ x: . value( " Day " , point. date, unit: . day) ,
190+ y: . value( " Messages " , point. count)
185191 )
186- )
187- . interpolationMethod ( . catmullRom)
192+ . foregroundStyle (
193+ . linearGradient(
194+ colors: [ ThemeColors . chartAccent. opacity ( 0.3 ) , ThemeColors . chartAccent. opacity ( 0.1 ) ] ,
195+ startPoint: . top,
196+ endPoint: . bottom
197+ )
198+ )
199+ . interpolationMethod ( . catmullRom)
188200
189- LineMark (
190- x: . value( " Day " , point. date, unit: . day) ,
191- y: . value( " Messages " , point. count)
192- )
193- . foregroundStyle ( ThemeColors . chartAccent)
194- . lineStyle ( StrokeStyle ( lineWidth: 1.5 ) )
195- . interpolationMethod ( . catmullRom)
201+ LineMark (
202+ x: . value( " Day " , point. date, unit: . day) ,
203+ y: . value( " Messages " , point. count)
204+ )
205+ . foregroundStyle ( ThemeColors . chartAccent)
206+ . lineStyle ( StrokeStyle ( lineWidth: 1.5 ) )
207+ . interpolationMethod ( . catmullRom)
196208
197- PointMark (
198- x: . value( " Day " , point. date, unit: . day) ,
199- y: . value( " Messages " , point. count)
200- )
201- . foregroundStyle ( ThemeColors . chartAccent)
202- . symbolSize ( 12 )
209+ PointMark (
210+ x: . value( " Day " , point. date, unit: . day) ,
211+ y: . value( " Messages " , point. count)
212+ )
213+ . foregroundStyle ( ThemeColors . chartAccent)
214+ . symbolSize ( 12 )
215+ }
216+
217+ if let selectedId = selectedDailyId,
218+ let point = data. first ( where: { $0. id == selectedId } ) {
219+ RuleMark ( x: . value( " Selected " , point. date, unit: . day) )
220+ . foregroundStyle ( ThemeColors . tertiaryLabel)
221+ . lineStyle ( Self . selectionRuleStyle)
222+ . annotation ( position: . top, spacing: 4 ) {
223+ tooltipLabel ( " \( point. count) msgs " )
224+ }
225+ }
203226 }
204227 . chartXAxis {
205228 AxisMarks ( values: dates) { value in
@@ -213,6 +236,28 @@ struct ActivityChartView: View {
213236 }
214237 . chartYAxis { sharedYAxis }
215238 . chartPlotStyle { plot in plot. background ( . clear) }
239+ . chartOverlay { proxy in
240+ GeometryReader { geo in
241+ Rectangle ( ) . fill ( . clear) . contentShape ( Rectangle ( ) )
242+ . onContinuousHover { phase in
243+ switch phase {
244+ case . active( let location) :
245+ let origin = geo [ proxy. plotAreaFrame] . origin
246+ let x = location. x - origin. x
247+ if let date: Date = proxy. value ( atX: x) {
248+ let cal = Calendar . current
249+ selectedDailyId = data
250+ . min ( by: {
251+ abs ( cal. dateComponents ( [ . hour] , from: $0. date, to: date) . hour ?? . max)
252+ < abs( cal. dateComponents ( [ . hour] , from: $1. date, to: date) . hour ?? . max)
253+ } ) ? . id
254+ }
255+ case . ended:
256+ selectedDailyId = nil
257+ }
258+ }
259+ }
260+ }
216261 . frame ( height: 50 )
217262 . accessibilityElement ( children: . ignore)
218263 . accessibilityLabel ( a11yLabel)
@@ -231,27 +276,39 @@ struct ActivityChartView: View {
231276 return " 12-hour activity chart. \( total) messages in trailing window "
232277 } ( )
233278
234- return Chart ( data) { point in
235- AreaMark (
236- x: . value( " Hour " , point. id) ,
237- y: . value( " Messages " , point. count)
238- )
239- . foregroundStyle (
240- . linearGradient(
241- colors: [ ThemeColors . chartAccent. opacity ( 0.3 ) , ThemeColors . chartAccent. opacity ( 0.1 ) ] ,
242- startPoint: . top,
243- endPoint: . bottom
279+ return Chart {
280+ ForEach ( data) { point in
281+ AreaMark (
282+ x: . value( " Hour " , point. id) ,
283+ y: . value( " Messages " , point. count)
244284 )
245- )
246- . interpolationMethod ( . catmullRom)
285+ . foregroundStyle (
286+ . linearGradient(
287+ colors: [ ThemeColors . chartAccent. opacity ( 0.3 ) , ThemeColors . chartAccent. opacity ( 0.1 ) ] ,
288+ startPoint: . top,
289+ endPoint: . bottom
290+ )
291+ )
292+ . interpolationMethod ( . catmullRom)
247293
248- LineMark (
249- x: . value( " Hour " , point. id) ,
250- y: . value( " Messages " , point. count)
251- )
252- . foregroundStyle ( ThemeColors . chartAccent)
253- . lineStyle ( StrokeStyle ( lineWidth: 1.5 ) )
254- . interpolationMethod ( . catmullRom)
294+ LineMark (
295+ x: . value( " Hour " , point. id) ,
296+ y: . value( " Messages " , point. count)
297+ )
298+ . foregroundStyle ( ThemeColors . chartAccent)
299+ . lineStyle ( StrokeStyle ( lineWidth: 1.5 ) )
300+ . interpolationMethod ( . catmullRom)
301+ }
302+
303+ if let selectedOffset = selectedHourlyOffset,
304+ let point = data. first ( where: { $0. id == selectedOffset } ) {
305+ RuleMark ( x: . value( " Selected " , point. id) )
306+ . foregroundStyle ( ThemeColors . tertiaryLabel)
307+ . lineStyle ( Self . selectionRuleStyle)
308+ . annotation ( position: . top, spacing: 4 ) {
309+ tooltipLabel ( " \( Self . formatHourLabel ( point. hour) ) :00 — \( point. count) msgs " )
310+ }
311+ }
255312 }
256313 . chartXAxis {
257314 AxisMarks ( values: [ 0 , 3 , 6 , 9 , 11 ] ) { value in
@@ -266,6 +323,23 @@ struct ActivityChartView: View {
266323 . chartXScale ( domain: 0 ... 11 )
267324 . chartYAxis { sharedYAxis }
268325 . chartPlotStyle { plot in plot. background ( . clear) }
326+ . chartOverlay { proxy in
327+ GeometryReader { geo in
328+ Rectangle ( ) . fill ( . clear) . contentShape ( Rectangle ( ) )
329+ . onContinuousHover { phase in
330+ switch phase {
331+ case . active( let location) :
332+ let origin = geo [ proxy. plotAreaFrame] . origin
333+ let x = location. x - origin. x
334+ if let value: Double = proxy. value ( atX: x) {
335+ selectedHourlyOffset = max ( 0 , min ( 11 , Int ( value. rounded ( ) ) ) )
336+ }
337+ case . ended:
338+ selectedHourlyOffset = nil
339+ }
340+ }
341+ }
342+ }
269343 . frame ( height: 50 )
270344 . accessibilityElement ( children: . ignore)
271345 . accessibilityLabel ( a11yLabel)
@@ -280,34 +354,46 @@ struct ActivityChartView: View {
280354 let actualTotal = dailyActivity. reduce ( 0 ) { $0 + $1. messageCount }
281355 let a11yLabel = " 12-month activity chart. \( actualTotal) messages total "
282356
283- return Chart ( data) { point in
284- AreaMark (
285- x: . value( " Month " , point. date, unit: . month) ,
286- y: . value( " Messages " , point. count)
287- )
288- . foregroundStyle (
289- . linearGradient(
290- colors: [ ThemeColors . chartAccent. opacity ( 0.3 ) , ThemeColors . chartAccent. opacity ( 0.1 ) ] ,
291- startPoint: . top,
292- endPoint: . bottom
357+ return Chart {
358+ ForEach ( data) { point in
359+ AreaMark (
360+ x: . value( " Month " , point. date, unit: . month) ,
361+ y: . value( " Messages " , point. count)
293362 )
294- )
295- . interpolationMethod ( . catmullRom)
363+ . foregroundStyle (
364+ . linearGradient(
365+ colors: [ ThemeColors . chartAccent. opacity ( 0.3 ) , ThemeColors . chartAccent. opacity ( 0.1 ) ] ,
366+ startPoint: . top,
367+ endPoint: . bottom
368+ )
369+ )
370+ . interpolationMethod ( . catmullRom)
296371
297- LineMark (
298- x: . value( " Month " , point. date, unit: . month) ,
299- y: . value( " Messages " , point. count)
300- )
301- . foregroundStyle ( ThemeColors . chartAccent)
302- . lineStyle ( StrokeStyle ( lineWidth: 1.5 ) )
303- . interpolationMethod ( . catmullRom)
372+ LineMark (
373+ x: . value( " Month " , point. date, unit: . month) ,
374+ y: . value( " Messages " , point. count)
375+ )
376+ . foregroundStyle ( ThemeColors . chartAccent)
377+ . lineStyle ( StrokeStyle ( lineWidth: 1.5 ) )
378+ . interpolationMethod ( . catmullRom)
304379
305- PointMark (
306- x: . value( " Month " , point. date, unit: . month) ,
307- y: . value( " Messages " , point. count)
308- )
309- . foregroundStyle ( ThemeColors . chartAccent)
310- . symbolSize ( 12 )
380+ PointMark (
381+ x: . value( " Month " , point. date, unit: . month) ,
382+ y: . value( " Messages " , point. count)
383+ )
384+ . foregroundStyle ( ThemeColors . chartAccent)
385+ . symbolSize ( 12 )
386+ }
387+
388+ if let selectedId = selectedMonthlyId,
389+ let point = data. first ( where: { $0. id == selectedId } ) {
390+ RuleMark ( x: . value( " Selected " , point. date, unit: . month) )
391+ . foregroundStyle ( ThemeColors . tertiaryLabel)
392+ . lineStyle ( Self . selectionRuleStyle)
393+ . annotation ( position: . top, spacing: 4 ) {
394+ tooltipLabel ( " \( Self . compactCount ( point. count) ) msgs " )
395+ }
396+ }
311397 }
312398 . chartXAxis {
313399 AxisMarks ( values: dates) { value in
@@ -321,6 +407,28 @@ struct ActivityChartView: View {
321407 }
322408 . chartYAxis { sharedYAxis }
323409 . chartPlotStyle { plot in plot. background ( . clear) }
410+ . chartOverlay { proxy in
411+ GeometryReader { geo in
412+ Rectangle ( ) . fill ( . clear) . contentShape ( Rectangle ( ) )
413+ . onContinuousHover { phase in
414+ switch phase {
415+ case . active( let location) :
416+ let origin = geo [ proxy. plotAreaFrame] . origin
417+ let x = location. x - origin. x
418+ if let date: Date = proxy. value ( atX: x) {
419+ let cal = Calendar . current
420+ selectedMonthlyId = data
421+ . min ( by: {
422+ abs ( cal. dateComponents ( [ . day] , from: $0. date, to: date) . day ?? . max)
423+ < abs( cal. dateComponents ( [ . day] , from: $1. date, to: date) . day ?? . max)
424+ } ) ? . id
425+ }
426+ case . ended:
427+ selectedMonthlyId = nil
428+ }
429+ }
430+ }
431+ }
324432 . frame ( height: 50 )
325433 . accessibilityElement ( children: . ignore)
326434 . accessibilityLabel ( a11yLabel)
@@ -530,6 +638,21 @@ struct ActivityChartView: View {
530638 }
531639 }
532640
641+ // MARK: - Tooltip annotation
642+
643+ /// Shared tooltip label styling used by all chart hover annotations.
644+ private func tooltipLabel( _ text: String ) -> some View {
645+ Text ( text)
646+ . font ( . system( . caption2, design: . monospaced) )
647+ . foregroundStyle ( ThemeColors . secondaryLabel)
648+ . padding ( . horizontal, 4 )
649+ . padding ( . vertical, 2 )
650+ . background ( . ultraThinMaterial, in: RoundedRectangle ( cornerRadius: 4 ) )
651+ }
652+
653+ /// Shared RuleMark styling for hover selection indicators.
654+ private static let selectionRuleStyle = StrokeStyle ( lineWidth: 0.5 , dash: [ 3 , 3 ] )
655+
533656 // MARK: - Formatters
534657
535658 static func dayShortLabel( _ date: Date ) -> String {
0 commit comments