linnarsson-lab / loom-viewer

Tool for sharing, browsing and visualizing single-cell data stored in the Loom file format
BSD 2-Clause "Simplified" License
35 stars 6 forks source link

Decouple plotter functions from React #56

Closed JobLeonard closed 7 years ago

JobLeonard commented 8 years ago

Our current set-up for plotting is a bit strangely intertwined:

In short, it's almost like a clear linear path, except that there is a back-and-forth going on between the plotter functions and canvas. Furthermore, only a small part of this chain really needs to involve React compoments: the part that is related to the sceen layouting. Everything else is either purely related to data-preparation, i.e. LandscapeView, or drawing on a context, i.e. Scatterplot.

What I want to do is untangle these functionalities and uncouple React as much as possible.

Step 1: rewrite the plotters as being just functions, without component logic

There is no need for Scatterplot or Sparkline to be a component: they just define and pass down a paint function to Canvas. This is the render function of both of these:

render() {
    return (
        <Canvas
            paint={this.paint}
            width={this.props.width}
            height={this.props.height}
            clear />
    );
}

With that in mind we might as well remove the whole component aspect and turn them into functions that generate a paint function, that we then can pass to a Canvas element.

This would remove the dependency on React as far as the plotter functions is concerned, which has two benefits:

In this set-up, Scatterplot would be a function that takes parameters x, y, color, colorMode, and optional flag parameters logScaleColor, logScaleX, logScaleY, and then returns a paint function that can be passed to a Canvas. This takes very little effort to rewrite since all I'm doing is stripping React Component logic.

Step 2: CanvasGrid & Printing

So here's the realization I had that triggered all of this: as far as the paint function is concerned, the width and height attached to the context are the width and height of the context, but it doesn't have to be. For example, this is what the Canvas component does now:

const { width, height, ratio } = this.state;
const canvas = this.refs.canvas;
let context = canvas.getContext('2d');
// store width, height and ratio in
// context for paint function
context.width = width;
context.height = height;
context.pixelRatio = ratio;
this.props.paint(context);

The result is that it draws to the full size of the canvas. But if we did this instead:

const { width, height, ratio } = this.state;
const canvas = this.refs.canvas;
let context = canvas.getContext('2d');
// store width, height and ratio in
// context for paint function
context.width = width/2;
context.height = height/2;
context.pixelRatio = ratio;
context.save();
// upper left
this.props.paint(context);
// upper right
context.translate(width/2, 0);
this.props.paint(context);
// bottom right
context.translate(0, height/2);
this.props.paint(context);
// bottom left
context.translate(-width/2, 0);
this.props.paint(context);
context.restore()

... we would draw the same plot four times in a 2x2 grid on one context. Note how simple this is in our existing architecture.

In other words: instead of creating, mounting, layouting individual canvases for every plot, we could create one big canvas and then plot on sub-sections of this canvas.

To do this, all we need to do is make a CanvasGrid component that takes an array of { paint, x, y, width, height } objects, with only the paint field being mandatory (we could even go nuts and do all kinds of rotation and stretching tricks with rotate and scale, but we don't really need that functionality and it would greatly complicate things - although I suppose rotations at 90 degree angles would be useful). CanvasGrid would then handle the logic of setting up the context before calling the provided paint functions. Simple! Note that this allows for only drawing the visible plots. A check whether or not x, y, width, height is contained within the context is extremely cheap, and we can simply skip calling the pain functions outside of the view. Let's say we have 50 sparklines, but only 15 fit in the view. That's 35 paint calls avoided - assuming that plotting is the most expensive part of every render loop (which is likely), that's a big boost in speed.

At the moment this only really affects the sparklines view, but given the print-outs I see on Hannah' and Lars' desk, looks likely that they will want to render dozens of sparklines at once. In that case this method should be significantly faster, and lighter on memory usage, especially on mobile.

This situation does complicate questions like scrolling behaviour, but we need to tackle those issues anyway if we want to make the canvas interactive so I don't think that's a significant issue here.

Finally, this set-up allows for a really elegant way to render print-versions of our plots:

UI wise we can just add a print button to the settings view, plus some options ( A4, US-letter, custom size in mm, 300DPI, 72DPI).

JobLeonard commented 8 years ago

BTW, this isn't a proposal; I'm going to do this since all of our architecture is pretty much ready to do it. Step one is a hour of work, step two is one, two days of work, tops. This is just documentation/clarification for myself.

JobLeonard commented 8 years ago

Step 1 done during the flight from Stockholm to Amsterdam and pushed to the server using the internet in the train to my parents :)

JobLeonard commented 7 years ago

So while creating a CanvasGrid element (the basic frame of which is done) I realised that to fully make this work, it needs to handle all kinds of things the browser normally does for us. For example, scrolling:

... plus lots of edge-cases because browsers are inconsistent in handling these events. This isn't really worth the hassle of implementing at the moment.

However, working on this was still very useful for figuring out how to render print versions of the plots, so it's not all wasted effort.

JobLeonard commented 7 years ago

Closing this issue for now; instead of step 2 we're exploring Vega instead.