From 3c7fba368bc953dd3ffa89300877cb15c34a0fc5 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 26 Aug 2026 13:47:11 +1200 Subject: [PATCH 1/4] Skip invisible chart effects when rendering the loading placeholder BarChartView and LineChartView read the redaction reasons from the environment. While redacted as a placeholder they draw flat fills and drop the comparison marks, average line, annotations, selection, gestures, and animations, none of which are visible at placeholder opacity. Measured on a Debug build, iOS 26.5 simulator: first push into Stats 367 ms -> 343 ms (mean of 12 runs). --- .../JetpackStats/Charts/BarChartView.swift | 32 ++++++--- .../Charts/Helpers/ChartHelper.swift | 18 +++++ .../JetpackStats/Charts/LineChartView.swift | 66 ++++++++++--------- .../Utilities/ChartEnvironment.swift | 8 +++ 4 files changed, 82 insertions(+), 42 deletions(-) diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift index ddf6d20f8a2e..b008c4d9c498 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)], diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift b/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift index b5b19e3ad4da..27ee31e62e74 100644 --- a/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift @@ -49,6 +49,24 @@ 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 an x-axis with marks at unit boundaries aligned with the chart granularity. @AxisContentBuilder static func makeXAxis( diff --git a/Modules/Sources/JetpackStats/Charts/LineChartView.swift b/Modules/Sources/JetpackStats/Charts/LineChartView.swift index 46b7a3263634..4e09ae89323c 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) + } } } 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) + } +} From a9dfa4fbaf7878f16899a79aff604eecb1d105bb Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 26 Aug 2026 13:47:11 +1200 Subject: [PATCH 2/4] Use cheap axes for the chart loading placeholder A single blank x-axis label keeps the axis height and one preformatted y-axis label keeps the gutter width, instead of formatting a date or value per tick. Measured on a Debug build, iOS 26.5 simulator: 343 ms -> 334 ms (mean of 8 runs); baseline 365 ms. --- .../JetpackStats/Charts/BarChartView.swift | 34 +++++++++++----- .../Charts/Helpers/ChartHelper.swift | 39 ++++++++++++++++++- .../JetpackStats/Charts/LineChartView.swift | 34 +++++++++++----- 3 files changed, 85 insertions(+), 22 deletions(-) diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift index b008c4d9c498..a3b40c01eeb1 100644 --- a/Modules/Sources/JetpackStats/Charts/BarChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/BarChartView.swift @@ -249,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 27ee31e62e74..9cb6f76ae871 100644 --- a/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift @@ -67,14 +67,49 @@ struct ChartHelper { ) } + /// 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 4e09ae89323c..a6ac35a69b75 100644 --- a/Modules/Sources/JetpackStats/Charts/LineChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/LineChartView.swift @@ -235,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) + } } } } From 3ddac0557970886faae2f7496b59f73af06bbd3b Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 26 Aug 2026 13:47:11 +1200 Subject: [PATCH 3/4] Skip the previous-day series in the Today card sparkline placeholder SparklineChart reads the redaction reasons like the main charts: while redacted as a placeholder it draws a flat area fill and no previous-day line. Measured on a Debug build, iOS 26.5 simulator: about 10 ms off the first push into Stats. --- .../JetpackStats/Cards/TodayCard.swift | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TodayCard.swift b/Modules/Sources/JetpackStats/Cards/TodayCard.swift index b9c947db166d..e49b3ddcf8d7 100644 --- a/Modules/Sources/JetpackStats/Cards/TodayCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TodayCard.swift @@ -207,11 +207,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 +222,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( From e86fe1d1e4824b080f3f8d921785dd916303780a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Wed, 26 Aug 2026 13:47:11 +1200 Subject: [PATCH 4/4] Generate placeholder chart data once ChartCard regenerated random mock data on every body evaluation, so each re-render during loading produced a new ChartData and the placeholder chart re-laid out with different bars. The view model now keeps one copy per date range and granularity. The Today card's placeholder data is generated once per process for the same reason. --- .../Sources/JetpackStats/Cards/ChartCard.swift | 10 +++------- .../Cards/ChartCardViewModel.swift | 18 ++++++++++++++++++ .../Sources/JetpackStats/Cards/TodayCard.swift | 9 ++++++--- 3 files changed, 27 insertions(+), 10 deletions(-) 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 e49b3ddcf8d7..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)