Skip to content

Commit c067fb3

Browse files
authored
feat: add interactive hover tooltips to activity charts (#107)
* feat: add interactive hover tooltips to activity charts Show message counts when hovering over chart data points across all three modes (12H, 7D, 12M). Uses chartOverlay with onContinuousHover to track cursor position and displays a RuleMark with annotation at the nearest data point. Tooltip disappears on mouse exit and clears on mode switch. * refactor: extract shared tooltip styling and rule mark constants Deduplicates annotation styling across all 3 chart modes into tooltipLabel() and selectionRuleStyle. --------- Co-authored-by: KyleNesium <22541778+KyleNesium@users.noreply.github.com>
1 parent 4f0ed92 commit c067fb3

File tree

1 file changed

+193
-70
lines changed

1 file changed

+193
-70
lines changed

AIBattery/Views/ActivityChartView.swift

Lines changed: 193 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)