Closed vojtamolda closed 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.
Wow this is a great explanation! Thank you @vojtamolda !
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.
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.
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.
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])
]
)
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")
)
)
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])
Implemented in release 0.5.0
.
_"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 ofPlotly.swift
and theplotly
js library."_ - Issue #16 @ProfFan