Search code examples
swiftuiswiftui-charts

Swift Charts: How to show only values and labels for values in array?


I have a Chart with WeatherKit.HourWeather objects spanning over multiple days on the x axis. However, I want to exclude the nighttime hours. It looks like I can do this with the chartXScale modifier like this:

let myDataSeperatedByHours = arrayWithAllDates.filter { ... }.sorted(...) // Array of WeatherKit.HourWeather objects filtered by isDaylight = true and sorted by date
let allDaytimeDates = myDataSeperatedByHours.map { $0.date } //only the Date objects

Chart {
      ForEach(myDataSeperatedByHours, id: \.date) { hourData in
           LineMark(
                x: .value("hour", hourData.date),
                y: .value("value", hourData.value)
           )
      }
                       

}
.chartXAxis {
        AxisMarks(position: .bottom, values: allDaytimeDates) { axisValue in
            if let date = axisValue.as(Date.self) {
                AxisValueLabel(
                    "\(Self.shortTimeFormatter.calendar.component(.hour, from: date))"
                )
            }
        }
    }
.chartXScale(domain: allDaytimeDates, type: .category)

However the Chart still displays part where there is no value. (the nighttime) I want everything removed when there is night. I've marked it green on the image below. Maybe I have to use two Charts next to each other. One for every day, but I can't believe that there's no way to do it with one Chart only.

enter image description here

I've created an example app that you can download and test here: https://github.com/Iomegan/DateChartIssue


Solution

  • As per chart scale modifier documentation for domain parameter:

    The possible data values along the x axis in the chart. You can define the domain with a ClosedRange for number or Date values (e.g., 0 ... 500), and with an array for categorical values (e.g., ["A", "B", "C"])

    It seems for date type values this function is expecting a range but since you are specifying an array the method invocation traps.

    Instead of providing the domain directly, you can provide an automatic scale domain modifying the inferred domain. To set the domain to your calculated allDaytimeDates use:

    .chartXScale(domain: .automatic(dataType: Date.self) { dates in
        dates = allDaytimeDates
    })
    

    Update 1

    There are multiple approaches you can try to ignore night time date scale on X-axis. The simpler and not recommended approach is to provided X-axis value in your line mark as a String instead of a Date.

    The issue with specifying X-axis value as Date is you can only supply a range for the axis scale and you can't just pick multiple ranges as scale for your axis as of now and similarly you can't specify your scale to ignore certain range or values (i.e. night time). With specifying X-axis value as string you will be able to just ignore night time values:

    LineMark(
        x: .value("hour", "\(hourData.date)"),
        y: .value("value", hourData.value)
    )
    

    The demerit with this approach is temprature variations as obtained from this graph is wrong as all your data points will be just separated equally regardless of their date value.

    The preferred approach is to manually adjust the X-axis position for next day's data points. For your scenario you can create a DayHourWeather type with custom X-position value:

    struct DayHourWeather: Plottable {
        let position: TimeInterval // Manually calculated X-axis position
        let date: Date
        let temp: Double
        let series: String // The day this data belongs to
    
        var primitivePlottable: TimeInterval { position }
        init?(primitivePlottable: TimeInterval) { nil }
    
        init(position: TimeInterval, date: Date, temp: Double, series: String) {
            self.position = position
            self.date = date
            self.temp = temp
            self.series = series
        }
    }
    

    You can customize the position data to move daytime plots closer together ignoring night time values. Then you can create DayHourWeathers from your HourWeathers:

    /// assumes `hourWeathers` are filtered containing only day time data and already sorted
    func getDayHourWeathers(from hourWeathers: [HourWeather]) -> [DayHourWeather] {
        let padding: TimeInterval = 10000 // distance between lat day's last data point and next day's first data point
        var translation: TimeInterval = 0 // The negetive translation required on X-axis for certain day
        var series: Int = 0 // Current day series
        var result: [DayHourWeather] = []
        result.reserveCapacity(hourWeathers.count)
        for (index, hourWeather) in hourWeathers.enumerated() {
            defer {
                result.append(
                    .init(
                        position: hourWeather.date.timeIntervalSince1970 - translation,
                        date: hourWeather.date,
                        temp: hourWeather.temp,
                        series: "Day \(series + 1)"
                    )
                )
            }
    
            guard
                index > 0,
                case let lastWeather = hourWeathers[index - 1],
                !Calendar.current.isDate(lastWeather.date, inSameDayAs: hourWeather.date)
            else { continue }
    
            // move next day graph to left occupying previous day's night scale
            translation = hourWeather.date.timeIntervalSince1970 - (result.last!.position + padding)
            series += 1
        }
        return result
    }
    

    Now to plot your chart you can use the newly created DayHourWeather values:

    var body: some View {
        let dayWeathers = getDayHourWeathers(from: myDataSeperatedByHours)
    
        Chart {
            ForEach(dayWeathers, id: \.date) { hourData in
                LineMark(
                    x: .value("hour", hourData.position), // custom X-axis position calculated
                    y: .value("value", hourData.temp)
                )
                .foregroundStyle(by: .value("Day", hourData.series))
            }
        }
        .chartXScale(domain: dayWeathers.first!.position...dayWeathers.last!.position) // provide scale range for calculated custom X-axis positions
    }
    

    Note that with above changes your X-axis marker will display your custom X-axis positions. To change it back to the actual date label you want to display you can specify custom X-axis label:

    
    .chartXAxis {
        AxisMarks(position: .bottom, values: dayWeathers) {
            AxisValueLabel(
                "\(Self.shortTimeFormatter.calendar.component(.hour, from: dayWeathers[$0.index].date))"
            )
        }
    }
    

    The values argument for AxisMarks only accepts an array of Plottable items, this is why confirming DayHourWeather to Plottable is needed. After above changes the chart obtained will look similar to this:

    Final plot

    Note that I have created a different series for each day data. Although you can combine them into a single series, I will advise against doing so as the resulting chart is misleading to viewer since you are removing part of the X-axis scale.