Search code examples
swiftswiftuiswiftui-charts

How to add a trend line to a line chart? (Two LineMarks on same chart)


How can I create this desired chart using Apple's SwiftUI Charts?

Desired Chart (mockup) - The stepped line graph with data points is in blue and the trend line is in red:

enter image description here

A trend line needs to be added to an existing chart that contains a stepped line graph. No SwiftUI Chart marks seemed to apply for a sloped trend line other than using another LineMark.

The trend line code, provided below, works when used with a scatter diagram(PointMark) only. But when the trend line is used with a line graph, that is, another LineMark then the trend line becomes a continuation of the first LineMark. When two LineMarks are used, the modifiers on the second LineMark are ignored.

Unwanted Result - When using two LineMarks in SwiftUI Charts, this is the unwanted result instead of the chart above:

enter image description here

Yet the trend line code works as desired when the other LineMark is removed:

enter image description here

The complete example code with example data:

import SwiftUI
import Charts

struct ContentView: View {
    
    @State var showSteps: Bool = false
    @State var showTrend: Bool = false
    
    var body: some View {
        VStack {
            Chart {
                
                    //Main data
                ForEach(dataPoints.filter({ data in data.type == .data}), id: \.id) { point in
                    if showSteps {
                        LineMark(
                            x: PlottableValue.value("Date", point.date),
                            y: PlottableValue.value("Amount", point.amount)
                        )
                        .interpolationMethod(.stepEnd)
                    }
                    
                    PointMark(
                        x: .value("Date", point.date),
                        y: .value("Amount", point.amount)
                    )
                }
                
                    // Trend line
                if showTrend {
                    ForEach(dataPoints.filter({ data in data.type == .trend}), id: \.id) { trend in
                        LineMark(
                            x: PlottableValue.value("Date", trend.date),
                            y: PlottableValue.value("Amount", trend.amount)
                        )
                        .foregroundStyle(Color.red)
                        .lineStyle(StrokeStyle(lineWidth: 5))
                        .interpolationMethod(.linear)
                    }
                }
            }
            .padding(.all)
            .frame(height: 500)
            .chartXScale(domain: Date(timeIntervalSinceNow: 3600 * 24 * -15) ... Date(timeIntervalSinceNow: 3600 * 24 * 15))
            .chartYAxis {
                AxisMarks(preset: .aligned, position: .automatic, values: .stride(by: 5)) {
                    let value = $0.as(Int.self) ?? 0
                    AxisGridLine()
                    AxisValueLabel(String("\(value)"))
                }
            }
            //.chartScrollableAxes(.horizontal) // future use
            Group {
                Toggle("Show Steps", isOn: $showSteps)
                Toggle("Show Trend", isOn: $showTrend)
            }.padding(.horizontal)
        }
    }
}

enum DataType {
    case data
    case trend
}

struct DataPoint: Hashable, Identifiable {
    var id       : UUID = UUID()
    var type   : DataType
    var date   : Date
    var amount : Double
}

let dataPoints: [DataPoint] = [
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -14), amount: 100),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -13), amount: 97),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -10), amount: 85),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -9), amount: 84),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -8), amount: 82),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -7), amount: 78),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -6), amount: 75),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -5), amount: 68),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -4), amount: 65),
    DataPoint(id: UUID(), type: .data, date: Date(timeIntervalSinceNow: 3600 * 24 * -3), amount: 50),
    DataPoint(id: UUID(), type: .data, date: Date(), amount: 48),
    DataPoint(id: UUID(), type: .trend, date: Date(timeIntervalSinceNow: 3600 * 24 * -15), amount: 100),
    DataPoint(id: UUID(), type: .trend, date: Date(timeIntervalSinceNow: 3600 * 24 * 15), amount: 0)
]

A solution will need to work with a scrollable chart as the final chart will use a large timeframe. (scrollable modifier included but commented out)

Tried different interpolation methods on linear graphs, splitting the data into two arrays, using a chart overlay and creating a trendline with a CGPath. One of these might be a solution but I may have missed how to properly set it up.

Any tips on how to get the desired trend line working correctly with another LineMark? Or how to create a trend line without using a LineMark?


Solution

  • The line marks for the trend line and for the actual data should be in different series. Otherwise they are assumed to be in the same series and the points will be joined together.

    Add a series: parameter and give them different PlottableValues.

    if showSteps {
        LineMark(
            x: .value("Date", point.date),
            y: .value("Amount", point.amount),
            series: .value("Series", "Actual Data")
        )
        .interpolationMethod(.stepEnd)
    }
    
    if showTrend {
        ForEach(dataPoints.filter({ data in data.type == .trend}), id: \.id) { trend in
            LineMark(
                x: .value("Date", trend.date),
                y: .value("Amount", trend.amount),
                series: .value("Series", "Trend")
            )
            .foregroundStyle(Color.red)
            .lineStyle(StrokeStyle(lineWidth: 5))
            .interpolationMethod(.linear)
        }
    }