vojtamolda / Plotly.swift

Interactive data visualization library for Swift
https://vojtamolda.github.io/Plotly.swift/
MIT License
82 stars 8 forks source link

Simplify axis parameters #17

Closed vojtamolda closed 3 years ago

vojtamolda commented 3 years ago

_"The main problem with the current interface is that you can create plots that is perfectly valid in schema but not actually doing what you want. For example, the axis parameter is really tricky to use as it just doesn't work and there is no error message when you don't understand the inner workings of Plotly.swift and the plotly js library."_ - Issue #16 @ProfFan

vojtamolda commented 3 years ago

Just to make the problem clear, here's how shared axes and subplots work at the moment.

In JavaScript, there are two things that need to be associated to each other. The xaxis property of a trace needs to match an entry in layout. trace.xaxis is a simple string like "x"/"x1", "x2", "x3", etc... On the other hand layout.xaxis/xaxis1, layout.xaxis2, layout.xaxis3, etc.. are quite complex objects. They describe the range, title, tickmarks and many other properties that show up on the x axis of the final figure.

Association is done by matching the numbers at the end. String "x"/"x1" corresponds to the first xaxis/xaxis1 object , x2 to the second one xaxis2 and so on to infinity and beyond... The layout.xaxisN object doesn't have to explicitly exist and things still work bacause Plotly.js implicitly creates them. Or it is also possible to build the axis with a layout.grid property into a nice regular matrix-like arrangement of subplots. The Y axis behaves exactly the same way with a "x" -> "y" replacement.

Plotly.swift adopted the JavaScript behavior without modifications. The whole thing is very error prone and there's no error message nor warning when one makes a mistake. Since the non-existing axes are created automatically Plotly.js just makes up a new axis which frequently results in surprising behavior.

Here's an example in JavaScript:

var trace1 = {
  type: 'scatter',
  x: [1, 2, 3], y: [4, 5, 6],
  xaxis: 'x1', // Alternatively 'x' or omitted altogether
  yaxis: 'y1'  // Alternatively 'y' or omitted altogether
};

var trace2 = {
  type: 'scatter'
  x: [20, 30, 40], y: [50, 60, 70],
  xaxis: 'x2',
  yaxis: 'y2',
};

var layout = {
  grid: {rows: 1, columns: 2, pattern: 'independent'},
};

Plotly.newPlot('myDiv', [trace1, trace2], layout);

And here's the corresponding Swift code. The axis matching is done by using uid: Int parameter of the constructor.

let trace1 = Scatter(
    x: [1, 2, 3], y: [4, 5, 6],
    xAxis: .init(uid: 1),
    yAxis: .init(uid: 1)
)

let trace2 = Scatter(
    x: [20, 30, 40], y: [50, 60, 70],
    xAxis: .init(uid: 2),
    yAxis: .init(uid: 2)
)

let layout = Layout(
    grid: .init(rows: 1, columns: 2, pattern: .independent)
)

let figure = Figure(data: [trace1, trace2], layout: layout)

By re-using the same axis string (i.e. "x2") in multiple trace objects, you can achieve plotting on a shared x-axis. This is very flexible and allows plotting of various mixtures of traces on a shared x axis with an independent y axis or vice versa. Here's an example.

As usual in the open source world, the documentation for the entire problem is sparse. For a detailed comparison of more chart types one can use the unit tests. All subplot examples from Plotly.js webpage are currently implemented as this test suite.

ProfFan commented 3 years ago

Wow this is a great explanation! Thank you @vojtamolda !

vojtamolda commented 3 years ago

Enough is enough 👿

It's about time to fix the mess. I just found a nasty bug #18 in the subplots/axis logic introduced in the v0.4.0 release.

vojtamolda commented 3 years ago

Problem

As is outlined above, JavaScript's (and also Plotly.swift's as of `v0.4.0) implementation that use axis matching by name/uid doesn't exactly use the best of what Swift has to offer. There are is no type safety and manual uid matching makes it very easy to make a typo.

By default all traces of the same kind share their axes. So the final figure is "one chart" with no subplots. This is difficult to grasp for beginners and I'd like to make this explicitly visible in the source code.

Solution

Let's leave out grid matrix-like subplot layouts for later. I don't think any choices here will have any effect on it.

The primitive use cases should stay identical to JavaScript. The simple use cases should stay simple. Ideally, the design should imply that compiled code is correct. "Default" trace -> subplot axis association should "just work" like in JavaScript. Same goes for more advanced subplot/axis matching. The default behavior between JavaScript and Swift should match to the largest possible extent. There should also be no surprises for experts who already know Plotly well.

All axis-like objects use Swift's reference semantics. The XAxis, YAxis, Geo, Mapbox and others are all classes. This allows for cross referencing the same instance in multiple different traces and achieve sharing of the axis, i.e. placing the traces on the same axis system. The default trace constructors initializes the subplot properties to the .preset static member with uid equal to 1. This way it is possible to do identity checks of each instance against the .preset.

When a Figure is constructed, the constructor collects all the subplot axis from traces, checks for both unique uid and unique instance. Then it assigns the non-duplicated objects to the corresponding storage in layout.

As a simplification of the most common use case, there is an extension of [XAxis] with a static .preset(....) method that takes the same arguments as the constructor but allows for customization of the default axis shared by all traces that don't explicitly change their subplot axis to a non-preset value.

Examples

Primitive

Here's a figure with two traces and shared axes. The code is virtually identical to JavaScript and one doesn't need to worry about layout, subplots, axis or anything else.

Figure(
    data: [
        Scatter(x: [1, 2], y: [3, 4]),
        Scatter(x: [1, 2], y: [4, 5])
    ]
)

Simple

Here's a figure with two traces same as above, but now the axis are labelled. The trick here is that Scatter.xAxis is initialized to a static XAxis.preset instance. This makes the sharing of axes between traces explicitly visible in the source code of the library. The [XAxis].preset(...) function doesn't creates a new axis with the same uid as the XAxis.preset but all the other properties can be customized.

Figure(
    data: [
        Scatter(x: [1, 2], y: [3, 4]),
        Scatter(x: [1, 2], y: [4, 5])
    ],
    layout: Layout(
        xAxis: .preset(title: "X Label"),
        yAxis: .preset(title: "Y Label")
    )
)

Complex

This example is a more complex chart with a small inset plot in the top right corner. All the axes are created with a call to the constructor without explicitly involving the layout. The Layout.xAxis and .yAxis get filled in automatically in the constructor of the Figure by collecting the Scatter.xAxis attributes of the two traces.

let trace1 = Scatter(
    x: [1, 2, 3], y: [4, 3, 2],
    xAxis: .init(), yAxis: .init()
)
trace1.xAxis.anchor = .yAxis(trace1.yAxis)
trace1.yAxis.anchor = .xAxis(trace1.xAxis)

let trace2 = Scatter(
    x: [20, 30, 40], y: [30, 40, 50],
    xAxis: .init(domain: [0.6, 0.95]),
    yAxis: .init(domain: [0.6, 0.95])
)
trace2.xAxis.anchor = .yAxis(trace2.yAxis)
trace2.yAxis.anchor = .xAxis(trace2.xAxis)

let figure = Figure(data: [trace1, trace2])
vojtamolda commented 3 years ago

Implemented in release 0.5.0.