Search code examples
swiftuiswiftui-charts

How should we correctly combine the use of LinePlot and PointPlot in Swift Charts?


I'm curious, does anyone know how to combine the use of LinePlot and PointPlot with per-series styling? I'm getting unpredictable results, and I wonder if I'm doing something wrong.

Here's a small example, taking things only slightly further from a very basic scenario:

struct PlotPoint {
    let x: Double
    let y: Double
}

// prepopulate a table with the data we want to plot
let cosData: [PlotPoint] = stride(from: -3, to: 3.01, by: 0.5).map { x in
    PlotPoint(x: x, y: cos(x))
}
let sinData: [PlotPoint] = stride(from: -3, to: 3.01, by: 0.5).map { x in
    PlotPoint(x: x, y: sin(x))
}


struct LinePlotView: View {
    var body: some View {
        if #available(iOS 18.0, *) {
            Chart {
                LinePlot(sinData,
                         x: .value("x", \.x),
                         y: .value("y", \.y)
                )
                .foregroundStyle(by: .value("expression", "y=sin(x)"))
                PointPlot(sinData,
                         x: .value("x", \.x),
                         y: .value("y", \.y)
                )
                .foregroundStyle(by: .value("expression", "y=sin(x)"))
                .symbol(by: .value("expression", "y=sin(x)"))

                
                LinePlot(cosData,
                         x: .value("x", \.x),
                         y: .value("y", \.y)
                )
                .foregroundStyle(by: .value("expression", "y=cos(x)"))
                PointPlot(cosData,
                         x: .value("x", \.x),
                         y: .value("y", \.y)
                )
                .foregroundStyle(by: .value("expression", "y=cos(x)"))
                .symbol(by: .value("expression", "y=cos(x)"))
            }
            .chartXScale(domain: -3 ... 3)
            .chartYScale(domain: -1.5 ... 1.5)
            .padding()
        } else {
            Text("Not available on iOS < 18")
        }
    }
}

Chart with incorrect legend

The result is not entirely unreasonable, but if you look closely, you will see that the legend indicates that the symbol for y=cos(x) is a cross, while in the graph it is using a square. In more complicated scenarios, I think multiple symbols are being used at each point, superimposed on each other. What should I do differently for this to work correctly?


Solution

  • LinePlots can have symbols too! It's just that they are hollow (not filled with solid color). If you only have these two LinePlots

    Chart {
        LinePlot(cosData,
                 x: .value("x", \.x),
                 y: .value("y", \.y)
        )
        .foregroundStyle(by: .value("expression", "y=cos(x)"))
        .symbol(by: .value("expression", "y=cos(x)"))
    
        LinePlot(sinData,
                 x: .value("x", \.x),
                 y: .value("y", \.y)
        )
        .foregroundStyle(by: .value("expression", "y=sin(x)"))
        .symbol(by: .value("expression", "y=sin(x)"))
    }
    

    You get:

    enter image description here

    From my experiments, it seems like the symbols are being applied procedurally, according to the order in which you wrote your ChartContents.

    In your code, you first have your sin line, then the sin points, and here is where you set a symbol. After that, you have the cos line. Here, you did not set a symbol, so the symbol you previously set (for the sin points) are used. Then you have the cos points which use the cross symbol.

    The "squares" that you are seeing are not actually squares - they are the hollow circle symbols for the cos line ("inherited" from the sin points), with the cross symbols for the cos points overlaid on top.

    If you don't like the symbols being hollow, you can move all the PointPlots where you have set the symbol, to after all the LinePlots, so the line plots are not affected.


    P.S.

    This effect can be exaggerated by adding a size property to your data, and using symbolSize.

    struct PlotPoint {
        let x: Double
        let y: Double
        let size: CGFloat
    }
    
    let cosData: [PlotPoint] = stride(from: -3, to: 3.01, by: 0.5).map { x in
        PlotPoint(x: x, y: cos(x), size: 1000)
    }
    let sinData: [PlotPoint] = stride(from: -3, to: 3.01, by: 0.5).map { x in
        PlotPoint(x: x, y: sin(x), size: 1)
    }
    
    Chart {
        LinePlot(sinData,
                 x: .value("x", \.x),
                 y: .value("y", \.y)
        )
        .foregroundStyle(by: .value("expression", "y=sin(x)"))
        
        PointPlot(sinData,
                 x: .value("x", \.x),
                 y: .value("y", \.y)
        )
        .foregroundStyle(by: .value("expression", "y=sin(x)"))
        .symbol(by: .value("expression", "y=sin(x)"))
        .symbolSize(\.size)
        
        LinePlot(cosData,
                 x: .value("x", \.x),
                 y: .value("y", \.y)
        )
        .foregroundStyle(by: .value("expression", "y=cos(x)"))
        
        PointPlot(cosData,
                 x: .value("x", \.x),
                 y: .value("y", \.y)
        )
        .foregroundStyle(by: .value("expression", "y=cos(x)"))
        .symbol(by: .value("expression", "y=cos(x)"))
    }
    

    Symbol size of a PointPlot can't actually be changed, so .symbolSize(\.size) has no effect on point plots. Symbol size of LinePlots can be changed, and so the cos line gets affected by .symbolSize(\.size) put on the sin points, simply because the cos line is ordered after the sin points, and it has no set symbol size.

    enter image description here