.chartXScale not scaling domain of Chart as expected

Hi,

I'm currently wrestling with the .chartXScale(domain:) modifier in order to get my Chart to display correctly. The basics of the Chart look like this.

Chart(measurements, id: \.timestamp) { measurement in
    if let total = measurement.production?.total {
        BarMark(
            x: .value(
                "Timestamp",
                measurement.timestamp,
                unit: .weekOfYear,
                calendar: .current
            ),
            y: .value(
                "Solar production",
                 total
             )
         )
     }
}

As anyone familiar with Charts can see, I sort data into columns based on what week of the year the measurements belong to. Some of them can be null, and when they are, I still want space in the Chart where a BarMark would've been to be taken up, like week number 4 in this example chart (in which I've defaulted all measurements that are null in week 4 to 0, for demonstration purposes):

To achieve that, as I understand, I'm meant to use the .chartXScale(domain:) modifier, but when I apply the following modifier...

.chartXScale(domain: firstDayOfMonth...firstDayOfNextMonth)

... (where the domain is from the first day of the month to the first day of the next month), to the code above, I end up with this weird half step when the last week of measurements are all null:

For reference, here's how the domain dates are set in my minimum reproducible example:

firstDayOfMonth = Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: .now))!

firstDayOfNextMonth = Calendar.current.date(byAdding: .month, value: 1, to: firstDayOfMonth)!

Am I misusing this modifier somehow, or is this a bug? Would love some help figuring this out, thanks!

Answered by AppleCare Staff in 794848022

Currently, the bar placement is conditional (non-nil total). Can you make it unconditional, such that nils get mapped to zero values?

Currently, the bar placement is conditional (non-nil total). Can you make it unconditional, such that nils get mapped to zero values?

Currently, the bar placement is conditional (non-nil total). Can you make it unconditional, such that nils get mapped to zero values?

My intention is to differentiate between nil and 0. nil would mean there's data missing, and 0 would mean there technically is data present, the total value of the measurements is just 0.

So if I understand your question correctly, my answer would be: No, sadly not.

Got it, I see what you want. But I'm not sure I understand the full setup. The code on the top uses .weekOfYear and then the domain is not set to week boundaries but to month boundaries, and it's not clear to me how the two relate in your use. Have you a small, single self-contained example with data in place? You can even use tuples in an array like [(date0, value0), ...]

Got it, I see what you want. But I'm not sure I understand the full setup. The code on the top uses .weekOfYear and then the domain is not set to week boundaries but to month boundaries, and it's not clear to me how the two relate in your use. Have you a small, single self-contained example with data in place? You can even use tuples in an array like [(date0, value0), ...]

I apologize if things are not clear. It might simply be because I don't have a full understanding of how I'm supposed to use the modifier. Let me try and break it down.

My data set is an array of one measurement per day in a month (so I have about ~30 entries per array, naturally). This is the reason I try to set the domain of the chart from the first day in a month, to the last day in a month. I figured with that, the Chart would understand the timespan I'm trying to plot (no matter if I sort it all by .weekOfYear or not). Is that not the case? Should I set the domain in some different way when sorting the data by weekOfYear?

I'm not sure I get the last sentence? Tuples for the domain?

I do have an Xcode project with a minimum reproducible example, but I guess I can't share it here directly. Should I file a feedback with it, or share it in some other way?

The easiest thing would be to paste a self-contained piece of code with data that demonstrates the problem, eg. an array of tuples like this:

Chart([(id: 0, time: ..., value: ...), (id: 1, time: ..., value: nil), ...], id: \.id) {
    BarMark(...)...
}.chartXScale(...)

No need for an entire repo, just a minimal, but runnable example that exhibits the issue.

No need for an entire repo, just a minimal, but runnable example that exhibits the issue.

Alright! This should do:

import SwiftUI
import Charts

struct ChartWithoutNilValues: View {
    let firstDayOfMonth: Date
    let firstDayOfNextMonth: Date
    let measurements: [PerformanceMeasurement]
    
    init() {
        firstDayOfMonth = Date.now.firstDayOfMonth()!
        firstDayOfNextMonth = Calendar.current.date(byAdding: .month, value: 1, to: firstDayOfMonth)!
        
        measurements = DataGenerator.generateMeasurements(
            interval: DateInterval(start: firstDayOfMonth, end: firstDayOfNextMonth),
            component: .day
        )
    }
    
    var body: some View {
        Chart(measurements, id: \.timestamp) { measurement in
            BarMark(
                x: .value(
                    "Timestamp",
                    measurement.timestamp,
                    unit: .weekOfYear,
                    calendar: .current
                ),
                y: .value(
                    "Solar production",
                    measurement.production?.total ?? 0
                )
            )
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .weekOfYear)) { value in
                AxisValueLabel("\(value.index)", centered: true)
            }
        }
        .padding()
    }
}


struct ChartWithNilValues: View {
    let firstDayOfMonth: Date
    let firstDayOfNextMonth: Date
    let measurements: [PerformanceMeasurement]
    
    init() {
        firstDayOfMonth = Date.now.firstDayOfMonth()!
        firstDayOfNextMonth = Calendar.current.date(byAdding: .month, value: 1, to: firstDayOfMonth)!
        
        measurements = DataGenerator.generateMeasurements(
            interval: DateInterval(start: firstDayOfMonth, end: firstDayOfNextMonth),
            component: .day
        )
    }
    
    var body: some View {
        Chart(measurements, id: \.timestamp) { measurement in
            if let total = measurement.production?.total {
                BarMark(
                    x: .value(
                        "Timestamp",
                        measurement.timestamp,
                        unit: .weekOfYear,
                        calendar: .current
                    ),
                    y: .value(
                        "Solar production",
                        total
                    )
                )
            }
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .weekOfYear)) { value in
                AxisValueLabel("\(value.index)", centered: true)
            }
        }
        .padding()
        .chartXScale(domain: firstDayOfMonth...firstDayOfNextMonth)
    }
}

#Preview {
    VStack {
        ChartWithoutNilValues()
        ChartWithNilValues()
    }
    .padding()
}

struct DataGenerator {
    let currentYear = Calendar.current.component(.year, from: .now)
    let startOfDay = Calendar.current.startOfDay(for: .now)

    static func generateMeasurements(interval: DateInterval, component: Calendar.Component) -> [PerformanceMeasurement] {
        var measurements: [PerformanceMeasurement] = []

        let calendar = Calendar.current
        let dateComponents = calendar.dateComponents([component], from: interval.start, to: interval.end)
        guard let count = dateComponents.value(for: component) else { return [] }

        for i in 0 ..< count {
            if let time = calendar.date(byAdding: component, value: i, to: interval.start), time <= interval.end {
                let production = createProduction(from: i > count - 10 ? nil : randomNumber)
                let measurement = PerformanceMeasurement(
                    timestamp: time,
                    production: production
                )
                measurements.append(measurement)
            }
        }
        return measurements
    }

    static private var randomNumber: Double? {
        Double.random(in: 0 ... 50)
    }

    static private func createProduction(from total: Double?) -> PerformanceMeasurement.Production {
        guard let total else {
            return PerformanceMeasurement.Production(total: nil, sold: nil, consumed: nil)
        }
        let sold = Double.random(in: 0 ... total)
        let consumed = total - sold
        return PerformanceMeasurement.Production(total: total, sold: sold, consumed: consumed)
    }
}

struct PerformanceMeasurement: Codable, Equatable {
    let timestamp: Date
    let production: Production?

    struct Production: Codable, Equatable {
        let total: Double?
        let sold: Double?
        let consumed: Double?
    }
}

extension Date {
    func firstDayOfMonth(in calendar: Calendar = .current) -> Date? {
        calendar.date(from: calendar.dateComponents([.year, .month], from: self))
    }
}

Sorry I meant, minimal reproducer, to be efficient with our time as there are many things we're working on in parallel.

So just a few lines of code would be enough, I think it should fit in 20 lines or less. For data, you can inline an array of tuples as I described. No need for preview, extensions, structs etc. just a single Chart.

So just a few lines of code would be enough, I think it should fit in 20 lines or less. For data, you can inline an array of tuples as I described. No need for preview, extensions, structs etc. just a single Chart.

Apologies, I thought perhaps an example with a Preview would be beneficial for demonstration purposes. I'm happy to reduce the amount of code a bit. No more extra structs, previews or extensions!

However, I can't really get it down to 20 lines if I am to simulate my issue. I need a month of measurements and I'm not sure I can even create the array of ~30 items (with plenty of Calendar.current operations) in 20 lines of code. I hope about 40 lines of code is okay!

let cal: Calendar = .current

struct ChartWithNilValues2: View {
    let start: Date = cal.date(from: cal.dateComponents([.year, .month], from: .now))!
    let end: Date
    
    init() {
        end = cal.date(byAdding: .month, value: 1, to: start)!
    }
    
    var body: some View {
        Chart(generateMeasurements(start: start, end: end), id: \.0) { measurement in
            if let total = measurement.1 {
                BarMark(
                    x: .value("", measurement.0, unit: .weekOfYear, calendar: cal),
                    y: .value("", total)
                )
            }
        }
        .chartXAxis {
            AxisMarks(values: .stride(by: .weekOfYear)) { value in
                AxisValueLabel("\(value.index)", centered: true)
            }
        }
        .padding()
        .chartXScale(domain: start...end)
    }
    
    func generateMeasurements(start: Date, end: Date) -> [(Date, Double?)] {
        var measurements: [(Date, Double?)] = []
        let dateComponents = cal.dateComponents([.day], from: start, to: end)
        guard let count = dateComponents.value(for: .day) else { return [] }
        for i in 0 ..< count {
            if let time = cal.date(byAdding: .day, value: i, to: start), time <= end {
                let production = i > count - 10 ? nil : Double.random(in: 0 ... 50)
                let measurement = (time, production)
                measurements.append(measurement)
            }
        }
        return measurements
    }
}

One thing that I observed trimming this down, is that if I move the definition of the end date from the init function to the struct definition, the problem seems to go away. 🤔 Hope that can be of some kind of assistance!

Final note: I'm in Stockholm, Sweden. So Calendar.current is Central European Time for me, if that makes a difference in your assessment.

.chartXScale not scaling domain of Chart as expected
 
 
Q