Skip to content

Commit 701616b

Browse files
authored
[New Stats] Add support for tapping on individual bars (#24768)
* Add support for tapping on bars in bar chart * Update events * Adjust how offset is done * Update granularity for under 2 years * Fix an issue with bar chart showing incorrect labels depending on the site reporting time zone * Disable selection when both data points are zero * Add tap highlight when navigation down the timeline * Cleanup
1 parent 39eef1c commit 701616b

12 files changed

Lines changed: 269 additions & 69 deletions

File tree

Modules/Sources/JetpackStats/Analytics/StatsEvent.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ public enum StatsEvent {
8888
/// - "metric": The metric selected (e.g., "visitors", "views", "likes")
8989
case chartMetricSelected
9090

91+
/// Chart bar selected for drill-down
92+
/// - Parameters:
93+
/// - "from_granularity": The current granularity (e.g., "day", "month")
94+
/// - "metric": The metric being viewed
95+
/// - "value": The value of the selected bar
96+
case chartBarSelected
97+
9198
// MARK: - List Events
9299

93100
/// Top list item tapped

Modules/Sources/JetpackStats/Cards/ChartCard.swift

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import Charts
44
struct ChartCard: View {
55
@ObservedObject private var viewModel: ChartCardViewModel
66

7+
private var onDateRangeSelected: ((StatsDateRange) -> Void)?
8+
private var backButtonTitle: String?
9+
private var backButtonAction: (() -> Void)?
10+
711
private var dateRange: StatsDateRange { viewModel.dateRange }
812
private var metrics: [SiteMetric] { viewModel.metrics }
913
private var selectedMetric: SiteMetric { viewModel.selectedMetric }
@@ -37,7 +41,10 @@ struct ChartCard: View {
3741
viewModel.onAppear()
3842
}
3943
.overlay(alignment: .topTrailing) {
40-
moreMenu
44+
HStack(alignment: .center, spacing: 0) {
45+
backButton
46+
moreMenu
47+
}
4148
}
4249
.cardStyle()
4350
.grayscale(viewModel.isStale ? 1 : 0)
@@ -66,14 +73,32 @@ struct ChartCard: View {
6673
}
6774

6875
private func headerView(for metric: SiteMetric) -> some View {
69-
HStack {
76+
HStack(alignment: .center) {
7077
StatsCardTitleView(title: metric.localizedTitle, showChevron: false)
71-
Spacer(minLength: 44)
78+
Spacer(minLength: 0)
7279
}
80+
.animation(.spring, value: backButtonTitle)
7381
.accessibilityElement(children: .combine)
7482
.accessibilityLabel(Strings.Accessibility.cardTitle(metric.localizedTitle))
7583
}
7684

85+
@ViewBuilder
86+
private var backButton: some View {
87+
if let title = backButtonTitle, let action = backButtonAction {
88+
Button(action: action) {
89+
HStack(spacing: 4) {
90+
Image(systemName: "chevron.backward")
91+
.font(.system(size: 12, weight: .bold))
92+
Text(title)
93+
.font(.system(size: 14, weight: .medium))
94+
}
95+
.foregroundColor(Constants.Colors.blue)
96+
}
97+
.buttonStyle(.plain)
98+
.transition(.scale(scale: 0.9).combined(with: .opacity))
99+
}
100+
}
101+
77102
@ViewBuilder
78103
private var contentView: some View {
79104
VStack(spacing: Constants.step0_5) {
@@ -226,9 +251,42 @@ struct ChartCard: View {
226251
case .line:
227252
LineChartView(data: data)
228253
case .columns:
229-
BarChartView(data: data)
254+
BarChartView(data: data) { selection in
255+
handleDateSelection(selection, data: data)
256+
}
230257
}
231258
}
259+
260+
private func handleDateSelection(_ selection: Date, data: ChartData) {
261+
let calendar = viewModel.dateRange.calendar
262+
let component = data.granularity.component
263+
guard let interval = calendar.dateInterval(of: component, for: selection) else {
264+
return assertionFailure("invalid component or date")
265+
}
266+
let newDateRange = StatsDateRange(
267+
interval: interval,
268+
component: component,
269+
comparison: viewModel.dateRange.comparison,
270+
calendar: calendar
271+
)
272+
onDateRangeSelected?(newDateRange)
273+
viewModel.tracker?.send(.chartBarSelected)
274+
}
275+
276+
/// Configures the back button for navigation history
277+
func backButton(title: String?, action: (() -> Void)?) -> ChartCard {
278+
var copy = self
279+
copy.backButtonTitle = title
280+
copy.backButtonAction = action
281+
return copy
282+
}
283+
284+
/// Configures the action when a bar is tapped for drill-down navigation
285+
func onDateRangeSelected(_ action: @escaping (StatsDateRange) -> Void) -> ChartCard {
286+
var copy = self
287+
copy.onDateRangeSelected = action
288+
return copy
289+
}
232290
}
233291

234292
private struct CardGradientBackground: View {
@@ -267,8 +325,6 @@ enum ChartType: String, CaseIterable, Codable {
267325
}
268326
}
269327

270-
// MARK: - Preview
271-
272328
private struct ChartCardPreview: View {
273329
@StateObject var viewModel = ChartCardViewModel(
274330
configuration: ChartCardConfiguration(

Modules/Sources/JetpackStats/Cards/ChartCardConfiguration.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ struct ChartCardConfiguration: Codable {
55
var metrics: [SiteMetric]
66
var chartType: ChartType
77

8-
init(id: UUID = UUID(), metrics: [SiteMetric], chartType: ChartType = .line) {
8+
init(id: UUID = UUID(), metrics: [SiteMetric], chartType: ChartType = .columns) {
99
self.id = id
1010
self.metrics = metrics
1111
self.chartType = chartType

Modules/Sources/JetpackStats/Charts/BarChartView.swift

Lines changed: 117 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import Charts
33

44
struct BarChartView: View {
55
let data: ChartData
6+
var onDateSelected: ((Date) -> Void)? = nil
67

7-
@State private var selectedDate: Date?
88
@State private var selectedDataPoints: SelectedDataPoints?
9+
@State private var isDragging = false
10+
@State private var isLongPressing = false
11+
@State private var longPressLocation: CGPoint?
12+
@State private var tappedDataPoint: DataPoint?
913

1014
@Environment(\.context) var context
1115

@@ -31,15 +35,17 @@ struct BarChartView: View {
3135
.chartYScale(domain: yAxisDomain)
3236
.chartLegend(.hidden)
3337
.environment(\.timeZone, context.timeZone)
34-
.modifier(ChartSelectionModifier(selection: $selectedDate))
3538
.animation(.spring, value: ObjectIdentifier(data))
36-
.onChange(of: selectedDate) {
37-
selectedDataPoints = SelectedDataPoints.compute(for: $0, data: data)
39+
.chartOverlay { proxy in
40+
makeGesturesOverlayView(proxy: proxy)
3841
}
3942
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
4043
.accessibilityElement()
4144
.accessibilityLabel(Strings.Accessibility.chartContainer)
4245
.accessibilityHint(Strings.Accessibility.viewChartData)
46+
.onChange(of: ObjectIdentifier(data)) { _ in
47+
tappedDataPoint = nil
48+
}
4349
}
4450

4551
// MARK: - Chart Marks
@@ -48,7 +54,7 @@ struct BarChartView: View {
4854
private var currentPeriodBars: some ChartContent {
4955
ForEach(data.currentData) { point in
5056
BarMark(
51-
x: .value("Date", point.date, unit: data.granularity.component),
57+
x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar),
5258
y: .value("Value", point.value),
5359
width: .automatic
5460
)
@@ -68,7 +74,15 @@ struct BarChartView: View {
6874
}
6975

7076
private func getOpacityForCurrentPeriodBar(for point: DataPoint) -> CGFloat {
77+
if let tappedDataPoint, tappedDataPoint.id == point.id {
78+
return 1.0
79+
}
7180
guard let selectedDataPoints else {
81+
// If there's a tapped point, dim other bars
82+
if tappedDataPoint != nil {
83+
return 0.5
84+
}
85+
// If no selection and not tapped, check if data is incomplete
7286
let isIncomplete = context.calendar.isIncompleteDataPeriod(for: point.date, granularity: data.granularity)
7387
return isIncomplete ? 0.5 : 1
7488
}
@@ -79,7 +93,7 @@ struct BarChartView: View {
7993
private var previousPeriodBars: some ChartContent {
8094
ForEach(data.mappedPreviousData) { point in
8195
BarMark(
82-
x: .value("Date", point.date, unit: data.granularity.component),
96+
x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar),
8397
y: .value("Value", point.value),
8498
width: .automatic,
8599
stacking: .unstacked
@@ -113,7 +127,7 @@ struct BarChartView: View {
113127
private var significantPointAnnotations: some ChartContent {
114128
if let maxPoint = data.significantPoints.currentMax, data.currentData.count > 0 {
115129
PointMark(
116-
x: .value("Date", maxPoint.date, unit: data.granularity.component),
130+
x: .value("Date", maxPoint.date, unit: data.granularity.component, calendar: context.calendar),
117131
y: .value("Value", maxPoint.value)
118132
)
119133
.opacity(0)
@@ -123,7 +137,7 @@ struct BarChartView: View {
123137
metric: data.metric
124138
)
125139
// Important for drag selection to work correctly.
126-
.opacity(selectedDate == nil ? 1 : 0)
140+
.opacity(selectedDataPoints == nil ? 1 : 0)
127141
}
128142
}
129143
}
@@ -174,6 +188,7 @@ struct BarChartView: View {
174188
if let date = value.as(Date.self) {
175189
AxisValueLabel {
176190
ChartAxisDateLabel(date: date, granularity: data.granularity)
191+
.offset(x: -2) // Align it better with bars
177192
}
178193
}
179194
}
@@ -220,6 +235,100 @@ struct BarChartView: View {
220235
)
221236
}
222237
}
238+
239+
// MARK: - Gestures
240+
241+
private func makeGesturesOverlayView(proxy: ChartProxy) -> some View {
242+
GeometryReader { geometry in
243+
Rectangle()
244+
.fill(.clear)
245+
.contentShape(Rectangle())
246+
.onTapGesture { location in
247+
handleTapGesture(at: location, proxy: proxy, geometry: geometry)
248+
}
249+
.onLongPressGesture(minimumDuration: 0.3) {
250+
// Long press completed - keep showing annotation
251+
} onPressingChanged: { isPressing in
252+
if isPressing {
253+
// Long press started - show annotation at current location
254+
if let location = longPressLocation {
255+
isLongPressing = true
256+
selectedDataPoints = getSelectedDataPoints(at: location, proxy: proxy, geometry: geometry)
257+
}
258+
} else {
259+
// Long press ended - clear annotation
260+
isLongPressing = false
261+
longPressLocation = nil
262+
if !isDragging {
263+
selectedDataPoints = nil
264+
}
265+
tappedDataPoint = nil
266+
}
267+
}
268+
.simultaneousGesture(
269+
DragGesture(minimumDistance: 0)
270+
.onChanged { value in
271+
// Store location for long press
272+
longPressLocation = value.location
273+
274+
// Handle drag if moved enough
275+
if value.translation.width.magnitude > 8 || value.translation.height.magnitude > 8 {
276+
isDragging = true
277+
selectedDataPoints = getSelectedDataPoints(at: value.location, proxy: proxy, geometry: geometry)
278+
}
279+
}
280+
.onEnded { _ in
281+
isDragging = false
282+
longPressLocation = nil
283+
if !isLongPressing {
284+
selectedDataPoints = nil
285+
}
286+
tappedDataPoint = nil
287+
}
288+
)
289+
}
290+
}
291+
292+
private func handleTapGesture(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) {
293+
// Only handle tap if not dragging or long pressing
294+
guard !isDragging && !isLongPressing else { return }
295+
296+
guard let onDateSelected,
297+
data.granularity != .hour,
298+
let selection = getSelectedDataPoints(at: location, proxy: proxy, geometry: geometry),
299+
selection.current?.value != 0 || selection.previous?.value != 0 else {
300+
// Clear selection if tapping on empty area
301+
tappedDataPoint = nil
302+
return
303+
}
304+
tappedDataPoint = selection.current ?? selection.previous
305+
if let date = tappedDataPoint?.date {
306+
onDateSelected(date)
307+
}
308+
}
309+
310+
private func getSelectedDataPoints(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> SelectedDataPoints? {
311+
let origin = geometry[proxy.plotAreaFrame].origin
312+
let location = CGPoint(
313+
x: location.x - origin.x,
314+
y: location.y - origin.y
315+
)
316+
guard let date: Date = proxy.value(atX: location.x) else {
317+
return nil
318+
}
319+
// Calling `proxy.value(atX: location.x)` returns dates with a second
320+
// precision. But the data points are represented using the start of the
321+
// period. The chart needs to offset the selection so that
322+
// `SelectedDataPoints.compute` correctly finds the closest one.
323+
let interval: TimeInterval = {
324+
let now = Date()
325+
let interval = context.calendar.date(byAdding: data.granularity.component, value: 1, to: now)
326+
return (interval ?? now).timeIntervalSince(now)
327+
}()
328+
329+
let offsetDate = date.addingTimeInterval(-(interval / 4))
330+
return SelectedDataPoints.compute(for: offsetDate, data: data)
331+
}
223332
}
224333

225334
// MARK: - Preview

Modules/Sources/JetpackStats/Screens/TrafficTabView.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ struct TrafficTabView: View {
9292
switch viewModel {
9393
case let viewModel as ChartCardViewModel:
9494
ChartCard(viewModel: viewModel)
95+
.onDateRangeSelected { dateRange in
96+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
97+
self.viewModel.pushDateRange(dateRange)
98+
}
99+
.backButton(title: getBackButtonTitle()) {
100+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
101+
self.viewModel.popDateRange()
102+
}
95103
case let viewModel as TopListViewModel:
96104
TopListCard(viewModel: viewModel)
97105
default:
@@ -100,6 +108,14 @@ struct TrafficTabView: View {
100108
}
101109
}
102110

111+
private func getBackButtonTitle() -> String? {
112+
guard let range = viewModel.dateRangeNavigationStack.last else {
113+
return nil
114+
}
115+
let formatter = StatsDateRangeFormatter(timeZone: context.timeZone)
116+
return formatter.string(from: range.dateInterval)
117+
}
118+
103119
@ViewBuilder
104120
private func cardView(for card: TrafficCardViewModel) -> some View {
105121
makeItem(for: card)

0 commit comments

Comments
 (0)