Ridgeline plot with Swift Charts

Continuing on from my last post where I build a stream graph, I will take that same dataset of popular US baby names to build a different view. The goal this time is to make it easier to see the change in each name's individual popularity over time while keeping the context of the other names visible. I'll break out each name into its own area graph and offset it, so that they appear like a landscape of mountain ranges going into the distance.

First, I need to group my data by name so that I can control the visual offset for each name. I can use Dictionary.init(grouping:) for this.

let groupedDataPoints = Dictionary(
    grouping: babyDataPoints
) { dataPoint in
    dataPoint.name
}

It will create a Dictionary<String, [BabyNamesDataPoint]>, grouping my datapoints by name, so I can control how I plot each name separately.

To plot these values, I will create a new struct that conforms to ChartContent. This is like a SwiftUI View but for use inside a Chart. Breaking up a complex chart is not just useful for code organisation but also helps the compiler infer the types. I have found that if I'm having lots of compiler crashes, timeouts or strange errors, breaking up the chart into multiple smaller ChartContent building blocks resolves these issues.

struct AreaChartForName: ChartContent {
    let name: String
    let dataPoints: [BabyNamesDataPoint]
    
    var body: some ChartContent {
        // Body goes here
    }
}

Like SwiftUI's View protocol, ChartContent's body property is implicitly a ChartContentBuilder, so I can build the nested charts content using the same declarative pattern I'm used to.

Let's start with a simple AreaMark that is configured to be unstacked and coloured by the name.

struct AreaChartForName: ChartContent {
    let name: String
    let dataPoints: [BabyNamesDataPoint]
    
    var body: some ChartContent {
        ForEach(dataPoints) { point in
            AreaMark(
                x: .value("Year", point.year),
                y: .value("Proportion", point.proportion),
                stacking: .unstacked
            )
            .foregroundStyle(
                by: .value("Baby Name", point.name)
            )
        }
    }
}

Now I will use my AreaChartForName in the chart.

struct RidgeBabyChart: View {
    let groupedData: Dictionary<String, [BabyNamesDataPoint]>
    
    var body: some View {
        Chart {
            ForEach(
                Array(groupedData),
                id: \.key
            ) { (key, values) in
                AreaChartForName(
                    name: key,
                    dataPoints: values
                )
            }
        }
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names over time

The next step is to offset the area marks, so that each one appears on its own baseline. I have found that the best way to do this is to use the offset(y:) modifier. I need to offset the first marks the most, so that the distant ridges are behind those in-front.

struct RidgeBabyChart: View {
    let groupedData: Dictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        Chart {
            ForEach(
                Array(groupedData.enumerated()),
                id: \.offset
            ) { (offset, element) in
                let offsetFactor: CGFloat = CGFloat(
                    groupedData.count - 1 - offset
                ) * -35
                
                AreaChartForName(
                    name: element.key,
                    dataPoints: element.value
                )
                .offset(y: offsetFactor)
            }
        }
    }
}

So that I can offset each area mark by a different amount, I changed ForEach statement to operate on an enumerated array of values.

Screenshot of an unstacked area chart showing popularity of US baby names over time with each area offset up a little

I can see an issue here, the y-axis domain has been automatically scaled by Charts to fit the largest data value. However, this does not take into account the offset I have applied. What I need is the range of values for the y-axis to be increased, so that the height of each of these area marks is reduced. The chartYScale(domain:) modifier allows me to set a custom range for the y-axis.

struct RidgeBabyChart: View {
    let groupedData: Dictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        Chart {
            ...
        }
        .chartYScale(domain: 0 ... 0.2)
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names over time

The chart is starting to look nice, but since I'm using a dictionary, every time I run the application the order of the names changes. Swift Collections package provides an OrderedDictionary type that will help me ensure the order is stable. Furthermore, I would like to sort the names by their date of maximum popularity, doing so will naturally sort the names visually back to front.

let groupedDataPoints = Dictionary(
    grouping: babyDataPoints
) { dataPoint in
    dataPoint.name
}

let sortedGroupedDataPoints = groupedDataPoints
    .sorted { a, b in
        a.value.yearOfMaxProportion < b.value.yearOfMaxProportion
    }

let orderedDataPoints = OrderedDictionary(
    uniqueKeysWithValues: sortedGroupedDataPoints
)
extension Array where Element == BabyNamesDataPoint {
    var yearOfMaxProportion: Date {
        guard let first = self.first else {
            fatalError("There must be at least one data point!")
        }
        var year = first.year
        var proportion = first.proportion
        for element in self {
            if element.proportion > proportion {
                year = element.year
                proportion = element.proportion
            }
        }
        return year
    }
}

Using the ordered dictionary in my chart just requires changing the signature of groupedData.

struct RidgeBabyChart: View {
    let groupedData: OrderedDictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        ...
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names over time ordered from older to newer names

# Styling the Chart

That is so much easer to read! However, I feel it could look better. I want to first smooth the area sections a little bit. My aim is to show a general trend rather than read off individual values for a single year, so a smother line will do this better. I will add interpolationMethod() inside my AreaChartForName body to automatically adjust the data being plotted.

struct AreaChartForName: ChartContent {
    let name: String
    let dataPoints: [BabyNamesDataPoint]
    
    var body: some ChartContent {
        ForEach(dataPoints) { point in
            AreaMark(
                x: .value("Year", point.year),
                y: .value("Proportion", point.proportion),
                stacking: .unstacked
            )
            .interpolationMethod(.cardinal)
            .foregroundStyle(
                by: .value("Baby Name", point.name)
            )
        }
    }
}

My goal is to let users compare the relative difference between these names, so the y-axis labels are misleading and cluttering up the chart. To remove them I will use chartYAxis(.hidden).

struct RidgeBabyChart: View {
    let groupedData: Dictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        Chart {
            ...
        }
        .chartYScale(domain: 0 ... 0.2)
        .chartYAxis(.hidden)
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names with the area segments slightly smoothed and the y-ticks hidden

Looking at the chart, it feels a little off. Since I offset the area marks from the x-axis in spots where there is no data point for a given year, Charts opt to not render anything visually. This makes it hard to read the chart. I will try adding a RuleMark at the base of each of these subgraphs.

struct AreaChartForName: ChartContent {
    let name: String
    let dataPoints: [BabyNamesDataPoint]
    
    var body: some ChartContent {
        ForEach(dataPoints) { point in
            AreaMark(
                x: .value("Year", point.year),
                y: .value("Proportion", point.proportion),
                stacking: .unstacked
            )
            .interpolationMethod(.cardinal)
            .foregroundStyle(by: .value("Baby Name", point.name))
        }
        
        RuleMark(y: .value("", 0))
        .foregroundStyle(by: .value("Baby Name", name))
        .accessibilityHidden(true)
    }
}

Doing this inside of the AreaChartForName means that it will be offset up along with the AreaMark. In addition, I used the accessibilityHidden() modifier on the ruler, so it doesn't contribute to VoiceOver when reading the chart.

Screenshot of an unstacked area chart showing popularity of US baby names with a axis line added for each names sub-chart

So much better! Next, I feel like I should address the colours. My aim here is to have a visual feel that guides the eye across the graph and through time to the most recently popular names. A gentle gradient gives a better impression of distant mountains.

struct RidgeBabyChart: View {
    let groupedData: Dictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        Chart {
            ...
        }
        .chartYScale(domain: 0 ... 0.2)
        .chartYAxis(.hidden)
        .chartForegroundStyleScale(
            range: Gradient (
                colors: [
                    .blue,
                    .teal
                ]
            )
        )
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names with a axis line added for each names sub-chart

Visually this has the layering, but the overlapping sections are now very hard to see. Let me try adding an outline using a LineMark. Like the ruler, this is just for visual clarity, so should be hidden from accessibility.

struct AreaChartForName: ChartContent {
    ...
    
    var body: some ChartContent {
        ForEach(dataPoints) { point in
            AreaMark( ... )
            .interpolationMethod(.cardinal)
            .foregroundStyle(
                by: .value("Baby Name", point.name)
            )

            LineMark(
                x: .value("Year", point.year),
                y: .value("Proportion", point.proportion),
                series: .value("Baby Name", point.name)
            )
            .interpolationMethod(.cardinal)
            .accessibilityHidden(true)
            .foregroundStyle(.thinMaterial)
        }
        
        ...
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names with a axis line added for each names sub-chart

# Adding labels

I feel that I should add some labels to the leading edge of each subchart, so that it's easer to link the data to the name. I will create an annotation on the top of the RuleMark with leading alignment.

struct AreaChartForName: ChartContent {
    ...
    
    var body: some ChartContent {
        ForEach(dataPoints) { ... }
        
        RuleMark(y: .value("", 0))
        .foregroundStyle(by: .value("Baby Name", name))
        .annotation(
            position: .top, alignment: .leading, spacing: 2
        ) {
            Text(name).font(.subheadline)
        }
        .accessibilityHidden(true)
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names with a axis line added for each names sub-chart

I see an issue here, the label might sometimes overlap the value points. I could write some code to try to detect if this is going to happen and shift up the label, but instead I will extend the chart out on the leading edge by a small amount to provide enough space for the label. I can do this using the chartXScale(range:) modifier. I will also hide the automatically provided legend as it's now redundant by applying chartLegend(.hidden) modifier.

struct RidgeBabyChart: View {
    let groupedData: OrderedDictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        Chart { ... }
        .chartYScale(domain: 0 ... 0.2)
        .chartYAxis(.hidden)
        .chartForegroundStyleScale( ... )
        
        // New modifiers added
        .chartXScale(
            range: .plotDimension(startPadding: 60)
        )
        .chartLegend(.hidden)
    }
}
Screenshot of an unstacked area chart showing popularity of US baby names with a axis line added for each names sub-chart

# Supporting light and dark mode

So far I have just been building the chart in dark mode, but it's important to test how it will look in light mode as well.

Screenshot of an unstacked area chart showing popularity of US baby names with an axis line added for each names sub-chart

This looks good, but in the day time distant mountains are faint due to the atmosphere, and closer mountains are darker, so I will try reversing the gradient depending on colorScheme.

struct RidgeBabyChart: View {
    @Environment(\.colorScheme) var scheme
    
    let groupedData: OrderedDictionary<String, [BabyNamesDataPoint]>

    var body: some View {
        Chart {... }
        .chartYScale( ... )
        .chartYAxis( ...)
        .chartForegroundStyleScale(
            range: gradient
        )
        .chartXScale( ... )
        .chartLegend( ... )
    }
    
    var gradient: Gradient {
        var colors: [Color] = [
            .blue,
            .teal
        ]
        
        if scheme == .light {
            colors.reverse()
        }
        
        return Gradient (colors: colors)
    }
}

Dark mode is unchanged but light mode now feels more correct to the eye.

Screenshot of an unstacked area chart showing popularity of US baby names with an axis line added for each names sub-chart

You can find the code for this chart in our GitHub project, this includes the code to download and parse the CSV file.