diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index e820de15f83a..86420fa4aee2 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -77,7 +77,7 @@ struct ChartCard: View { } private func makeHeaderViewModel(for metric: SiteMetric) -> ChartCardHeaderView.ViewModel { - let data = viewModel.chartData[selectedMetric] ?? mockChartData + let data = viewModel.chartData[selectedMetric] ?? viewModel.placeholderChartData return ChartCardHeaderView.ViewModel( trend: viewModel.selectedBarTrend ?? .make(data, context: .regular), metricTitle: metric.localizedTitle, @@ -118,7 +118,7 @@ struct ChartCard: View { @ViewBuilder private var chartContentView: some View { if viewModel.isFirstLoad { - mainChartView(metric: selectedMetric, data: mockChartData) + mainChartView(metric: selectedMetric, data: viewModel.placeholderChartData) .redacted(reason: .placeholder) .opacity(0.2) .pulsating() @@ -155,7 +155,7 @@ struct ChartCard: View { } private func loadingErrorView(with message: String) -> some View { - mainChartView(metric: selectedMetric, data: mockChartData) + mainChartView(metric: selectedMetric, data: viewModel.placeholderChartData) .redacted(reason: .placeholder) .grayscale(1) .opacity(0.1) @@ -164,10 +164,6 @@ struct ChartCard: View { } } - private var mockChartData: ChartData { - ChartData.mock(metric: .views, granularity: dateRange.dateInterval.preferredGranularity, range: dateRange) - } - // MARK: - Header View private var moreMenu: some View { diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift index b3d0f0220c60..fca9e6afbc22 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift @@ -72,6 +72,24 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { var isFirstLoad: Bool { isLoading && chartData.isEmpty } + private var cachedPlaceholderChartData: ChartData? + + /// Mock chart data for the redacted placeholder. Generated once per date range and + /// granularity so re-renders keep the same random values instead of redrawing the chart. + var placeholderChartData: ChartData { + let range = effectiveDateRange + let granularity = effectiveGranularity + if let cached = cachedPlaceholderChartData, + cached.dateInterval == range.dateInterval, + cached.granularity == granularity + { + return cached + } + let data = ChartData.mock(metric: .views, granularity: granularity, range: range) + cachedPlaceholderChartData = data + return data + } + init( configuration: ChartCardConfiguration, dateRange: StatsDateRange, diff --git a/Modules/Sources/JetpackStats/Cards/TodayCard.swift b/Modules/Sources/JetpackStats/Cards/TodayCard.swift index b9c947db166d..a16c444ca38d 100644 --- a/Modules/Sources/JetpackStats/Cards/TodayCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TodayCard.swift @@ -76,7 +76,7 @@ struct TodayCard: View { if let data = viewModel.data { makeMetricsView(with: data.metrics) } else if viewModel.isLoading { - makeMetricsView(with: placeholderData.metrics) + makeMetricsView(with: Self.placeholderData.metrics) .redacted(reason: .placeholder) .opacity(0.66) .pulsating() @@ -106,7 +106,7 @@ struct TodayCard: View { if let data = viewModel.data { makeSparklineView(data) } else { - let placeholder = makeSparklineView(placeholderData) + let placeholder = makeSparklineView(Self.placeholderData) .redacted(reason: .placeholder) if viewModel.isLoading { placeholder.pulsating().opacity(0.33) @@ -131,7 +131,10 @@ struct TodayCard: View { // MARK: - Placeholder Data - private var placeholderData: TodayCardData { + /// Generated once so re-renders keep the same random noise instead of redrawing the sparkline. + private static let placeholderData: TodayCardData = makePlaceholderData() + + private static func makePlaceholderData() -> TodayCardData { // Generate hourly data points with a realistic curve peaking mid-day let hourlyViews = (0..<12).map { hour in let normalizedHour = Double(hour) @@ -207,11 +210,14 @@ private struct SparklineChart: View { let metric: SiteMetric @Environment(\.colorScheme) var colorScheme + @Environment(\.isPlaceholder) private var isPlaceholder var body: some View { Chart { current - previous + if !isPlaceholder { + previous + } } .chartXAxis(.hidden) .chartYAxis(.hidden) @@ -219,22 +225,18 @@ private struct SparklineChart: View { // Current day's data (colored area + line) private var current: some ChartContent { - ForEach(dataPoints, id: \.hour) { hour, value in + let areaStyle = ChartHelper.areaStyle( + color: metric.primaryColor, + colorScheme: colorScheme, + isPlaceholder: isPlaceholder + ) + return ForEach(dataPoints, id: \.hour) { hour, value in AreaMark( x: .value("Hour", hour), y: .value("Current", value), series: .value("Series", "Current") ) - .foregroundStyle( - LinearGradient( - colors: [ - metric.primaryColor.opacity(colorScheme == .light ? 0.15 : 0.25), - metric.primaryColor.opacity(0.0) - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .foregroundStyle(areaStyle) .interpolationMethod(.linear) LineMark( diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift index ddf6d20f8a2e..a3b40c01eeb1 100644 --- a/Modules/Sources/JetpackStats/Charts/BarChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/BarChartView.swift @@ -21,6 +21,7 @@ struct BarChartView: View { @Environment(\.context) var context @Environment(\.colorScheme) var colorScheme @Environment(\.showComparison) private var showComparison + @Environment(\.isPlaceholder) private var isPlaceholder private var valueFormatter: StatsValueFormatter { StatsValueFormatter(metric: data.metric) @@ -33,14 +34,16 @@ struct BarChartView: View { var body: some View { Chart { - if showComparison { + if showComparison, !isPlaceholder { previousPeriodBars } currentPeriodBars - averageLine - significantPointAnnotations - tappedBarAnnotation - selectionIndicatorMarks + if !isPlaceholder { + averageLine + significantPointAnnotations + tappedBarAnnotation + selectionIndicatorMarks + } } .chartXAxis { xAxis } .chartYAxis { yAxis } @@ -48,10 +51,12 @@ struct BarChartView: View { .chartYScale(domain: yAxisDomain) .chartLegend(.hidden) .environment(\.timeZone, context.timeZone) - .animation(.spring, value: ObjectIdentifier(data)) - .animation(.snappy, value: selectedBarDate) + .animation(isPlaceholder ? nil : .spring, value: ObjectIdentifier(data)) + .animation(isPlaceholder ? nil : .snappy, value: selectedBarDate) .chartOverlay { proxy in - makeGesturesOverlayView(proxy: proxy) + if !isPlaceholder { + makeGesturesOverlayView(proxy: proxy) + } } .dynamicTypeSize(...DynamicTypeSize.xxxLarge) .accessibilityElement() @@ -64,18 +69,25 @@ struct BarChartView: View { @ChartContentBuilder private var currentPeriodBars: some ChartContent { ForEach(data.currentData) { point in - let isIncomplete = context.calendar.isIncompleteDataPeriod(for: point.date, granularity: data.granularity) BarMark( x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar), y: .value("Value", point.value), width: .automatic ) - .foregroundStyle(isIncomplete ? AnyShapeStyle(incompleteBarPattern) : AnyShapeStyle(barGradient)) + .foregroundStyle(barStyle(for: point)) .cornerRadius(5) .opacity(getOpacityForPeriodBar(for: point)) } } + private func barStyle(for point: DataPoint) -> AnyShapeStyle { + if isPlaceholder { + return AnyShapeStyle(data.metric.primaryColor) + } + let isIncomplete = context.calendar.isIncompleteDataPeriod(for: point.date, granularity: data.granularity) + return isIncomplete ? AnyShapeStyle(incompleteBarPattern) : AnyShapeStyle(barGradient) + } + private var barGradient: LinearGradient { LinearGradient( colors: [data.metric.primaryColor, lighten(data.metric.primaryColor)], @@ -237,20 +249,34 @@ struct BarChartView: View { ChartHelper.makeXAxis( domain: xAxisDomain, granularity: data.granularity, - calendar: context.calendar + calendar: context.calendar, + isPlaceholder: isPlaceholder ) } + private var yAxisGridLineColor: Color { + Color.secondary.opacity(0.33) + } + + @AxisContentBuilder private var yAxis: some AxisContent { - AxisMarks(values: .automatic) { value in - if let value = value.as(Int.self) { - AxisGridLine() - .foregroundStyle(Color.secondary.opacity(0.33)) - AxisValueLabel { - if value > 0 { - Text(valueFormatter.format(value: value, context: .compact)) - .font(.caption2.weight(.medium)) - .foregroundColor(.secondary) + if isPlaceholder { + ChartHelper.makePlaceholderYAxis( + domain: yAxisDomain, + formatter: valueFormatter, + gridLineColor: yAxisGridLineColor + ) + } else { + AxisMarks(values: .automatic) { value in + if let value = value.as(Int.self) { + AxisGridLine() + .foregroundStyle(yAxisGridLineColor) + AxisValueLabel { + if value > 0 { + Text(valueFormatter.format(value: value, context: .compact)) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + } } } } diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift b/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift index b5b19e3ad4da..9cb6f76ae871 100644 --- a/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift @@ -49,14 +49,67 @@ struct ChartHelper { return periodStart...periodEnd } + /// The fill under a line chart's current-period line. Flat while rendering a + /// placeholder, where the gradient is invisible. + static func areaStyle(color: Color, colorScheme: ColorScheme, isPlaceholder: Bool) -> AnyShapeStyle { + if isPlaceholder { + return AnyShapeStyle(color.opacity(0.15)) + } + return AnyShapeStyle( + LinearGradient( + colors: [ + color.opacity(colorScheme == .light ? 0.15 : 0.25), + color.opacity(0.0) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + } + + /// Creates a placeholder x-axis: a single blank label keeps the axis height without + /// formatting a date per tick. + @AxisContentBuilder + private static func makePlaceholderXAxis(domain: ClosedRange) -> some AxisContent { + AxisMarks(values: [domain.lowerBound]) { _ in + AxisValueLabel(centered: true) { + Text(verbatim: " ") + .font(.caption2.weight(.medium)) + } + } + } + + /// Creates a placeholder y-axis: every tick shows the domain's widest label, which + /// keeps the gutter width without formatting a value per tick. + @AxisContentBuilder + static func makePlaceholderYAxis( + domain: ClosedRange, + formatter: StatsValueFormatter, + gridLineColor: Color + ) -> some AxisContent { + let label = formatter.format(value: domain.upperBound, context: .compact) + AxisMarks(values: .automatic) { _ in + AxisGridLine() + .foregroundStyle(gridLineColor) + AxisValueLabel { + Text(verbatim: label) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + } + } + } + /// Creates an x-axis with marks at unit boundaries aligned with the chart granularity. @AxisContentBuilder static func makeXAxis( domain: ClosedRange, granularity: DateRangeGranularity, - calendar: Calendar + calendar: Calendar, + isPlaceholder: Bool = false ) -> some AxisContent { - if granularity == .hour { + if isPlaceholder { + makePlaceholderXAxis(domain: domain) + } else if granularity == .hour { AxisMarks(preset: .automatic) { value in if let date = value.as(Date.self) { AxisValueLabel(centered: true) { diff --git a/Modules/Sources/JetpackStats/Charts/LineChartView.swift b/Modules/Sources/JetpackStats/Charts/LineChartView.swift index 46b7a3263634..a6ac35a69b75 100644 --- a/Modules/Sources/JetpackStats/Charts/LineChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/LineChartView.swift @@ -10,6 +10,7 @@ struct LineChartView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.context) var context @Environment(\.showComparison) private var showComparison + @Environment(\.isPlaceholder) private var isPlaceholder private var valueFormatter: StatsValueFormatter { StatsValueFormatter(metric: data.metric) @@ -23,12 +24,14 @@ struct LineChartView: View { var body: some View { Chart { currentPeriodMarks - if showComparison { + if showComparison, !isPlaceholder { previousPeriodMarks } - averageLine - significantPointAnnotations - selectionIndicatorMarks + if !isPlaceholder { + averageLine + significantPointAnnotations + selectionIndicatorMarks + } } .chartXAxis { xAxis } .chartYAxis { yAxis } @@ -36,8 +39,8 @@ struct LineChartView: View { .chartYScale(domain: yAxisDomain) .chartLegend(.hidden) .environment(\.timeZone, context.timeZone) - .chartXSelection(value: $selectedDate) - .animation(.spring, value: ObjectIdentifier(data)) + .chartXSelection(value: isPlaceholder ? .constant(nil) : $selectedDate) + .animation(isPlaceholder ? nil : .spring, value: ObjectIdentifier(data)) .onChange(of: selectedDate) { selectedDataPoints = SelectedDataPoints.compute(for: $1, data: data) } @@ -52,23 +55,20 @@ struct LineChartView: View { @ChartContentBuilder private var currentPeriodMarks: some ChartContent { - // Solid line and area for complete data points - ForEach(completeDataPoints) { point in + let areaStyle = ChartHelper.areaStyle( + color: data.metric.primaryColor, + colorScheme: colorScheme, + isPlaceholder: isPlaceholder + ) + // Solid line and area for complete data points. The placeholder draws every point + // solid; its dashed incomplete segment is invisible at placeholder opacity. + ForEach(isPlaceholder ? data.currentData : completeDataPoints) { point in AreaMark( x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar), y: .value("Value", point.value), series: .value("Period", "Current") ) - .foregroundStyle( - LinearGradient( - colors: [ - data.metric.primaryColor.opacity(colorScheme == .light ? 0.15 : 0.25), - data.metric.primaryColor.opacity(0.0) - ], - startPoint: .top, - endPoint: .bottom - ) - ) + .foregroundStyle(areaStyle) .interpolationMethod(.linear) LineMark( @@ -86,20 +86,22 @@ struct LineChartView: View { } // Dashed line segment connecting the last complete point to today's incomplete point - ForEach(incompleteSegmentPoints) { point in - LineMark( - x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar), - y: .value("Value", point.value), - series: .value("Period", "Incomplete") - ) - .foregroundStyle(data.metric.primaryColor.opacity(0.4)) - .lineStyle(StrokeStyle( - lineWidth: 3, - lineCap: .round, - lineJoin: .round, - dash: [6, 5] - )) - .interpolationMethod(.linear) + if !isPlaceholder { + ForEach(incompleteSegmentPoints) { point in + LineMark( + x: .value("Date", point.date, unit: data.granularity.component, calendar: context.calendar), + y: .value("Value", point.value), + series: .value("Period", "Incomplete") + ) + .foregroundStyle(data.metric.primaryColor.opacity(0.4)) + .lineStyle(StrokeStyle( + lineWidth: 3, + lineCap: .round, + lineJoin: .round, + dash: [6, 5] + )) + .interpolationMethod(.linear) + } } } @@ -233,20 +235,34 @@ struct LineChartView: View { ChartHelper.makeXAxis( domain: xAxisDomain, granularity: data.granularity, - calendar: context.calendar + calendar: context.calendar, + isPlaceholder: isPlaceholder ) } + private var yAxisGridLineColor: Color { + Color(.opaqueSeparator).opacity(0.5) + } + + @AxisContentBuilder private var yAxis: some AxisContent { - AxisMarks { value in - if let value = value.as(Int.self) { - AxisGridLine() - .foregroundStyle(Color(.opaqueSeparator).opacity(0.5)) - - AxisValueLabel { - Text(valueFormatter.format(value: value, context: .compact)) - .font(.caption2.weight(.medium)).tracking(-0.1) - .foregroundColor(.secondary) + if isPlaceholder { + ChartHelper.makePlaceholderYAxis( + domain: yAxisDomain, + formatter: valueFormatter, + gridLineColor: yAxisGridLineColor + ) + } else { + AxisMarks { value in + if let value = value.as(Int.self) { + AxisGridLine() + .foregroundStyle(yAxisGridLineColor) + + AxisValueLabel { + Text(valueFormatter.format(value: value, context: .compact)) + .font(.caption2.weight(.medium)).tracking(-0.1) + .foregroundColor(.secondary) + } } } } diff --git a/Modules/Sources/JetpackStats/Utilities/ChartEnvironment.swift b/Modules/Sources/JetpackStats/Utilities/ChartEnvironment.swift index aaabe43c328f..a5741a6d91f7 100644 --- a/Modules/Sources/JetpackStats/Utilities/ChartEnvironment.swift +++ b/Modules/Sources/JetpackStats/Utilities/ChartEnvironment.swift @@ -10,3 +10,11 @@ extension EnvironmentValues { set { self[ShowComparisonKey.self] = newValue } } } + +extension EnvironmentValues { + /// Whether the view is redacted as a loading placeholder. Charts use it to skip + /// effects that are invisible at placeholder opacity. + var isPlaceholder: Bool { + redactionReasons.contains(.placeholder) + } +}