Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 3 additions & 7 deletions Modules/Sources/JetpackStats/Cards/ChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 17 additions & 15 deletions Modules/Sources/JetpackStats/Cards/TodayCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -207,34 +210,33 @@ 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)
}

// 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(
Expand Down
66 changes: 46 additions & 20 deletions Modules/Sources/JetpackStats/Charts/BarChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -33,25 +34,29 @@ 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 }
.chartXScale(domain: xAxisDomain)
.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()
Expand All @@ -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)],
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
57 changes: 55 additions & 2 deletions Modules/Sources/JetpackStats/Charts/Helpers/ChartHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date>) -> 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<Int>,
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<Date>,
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) {
Expand Down
Loading