GSoC Week 2: Using setState as a "Queue"

This is the second in a series of posts about my GSoC project to develop a "Pathway Presenter" for WikiPathways.

Animation

Pvjs is the library used to render pathway diagrams in WikiPathways. I have built a "Manipulation API" for it that allows developers to trigger highlighting, hiding, zooming, and panning of the diagram. However, none of these operations are animated, as you can see in the GIF below.

Unanimated Pvjs diagram

This lack of animation is really a problem for pathway presentations for two reasons:

  1. When zooming, the viewer loses the context of where in the diagram the zoomed region is.
  2. It's not a pleasurable UX.

So this week I endeavoured to at least animate the pan/zoom functionality. Pvjs uses the brilliant SVGPanZoom library to control the diagram. However, this library has no support for animation (yet) and is not a React library. The latter is a significant problem since I have to use refs to access the DOM node of the diagram to pass to SVGPanZoom. This is not recommended since it bypasses React's virtual DOM. There is also the problem of state - React doesn't know what pan/zoom state the diagram is in, potentially leading to errors when performing a pan/zoom. Since animation operations are intrinsically asynchronous, React must know that an animation is being performed so that another is not started before the first's completion.

I declare you, "declarative"

SVGPanZoom and the Manipulation API both use an imperative style. This is not the React way, which is extremely declarative. If you're unsure about what this means then I highly recommend this explanation by the awesome MPJ.

An extract of the code for the Manipulation API is given below to show you it's imperative nature. You can also see the full code here.

export class Manipulator {  
    constructor(kaavioRef, panZoomRef, diagramRef){
        this.panZoom = panZoomRef;
        this.kaavio = kaavioRef;
        this.diagram = ReactDOM.findDOMNode(diagramRef);
    }

    highlightOn(entity_id: string, color: string): void {
        if(! color) throw new Error("No color specified.");

        this.kaavio.pushHighlighted({
            node_id: entity_id,
            color: color
        });
    }

    zoomOn(entityId): void {
        const coordinates = COMPUTED_WITH_MAGIC;
        this.panZoom.zoom(coordinates);
    }
}

This mix of imperative code within an extremely declarative React codebase is very confusing. There is also very little compartmentalization - the code for zooming and panning is both in the PanZoom React component as well as in the Manipulation API (wuh?). I quickly realised this just wasn't maintainable when I began trying to animate things. I was changing the code in multiple places just to do one thing - not a good sign.

So began the journey to a purely declarative Pvjs component:

<Pvjs wpId="4" highlightedEntities={[{entityId: 'd8bae', color: 'red']} hiddenEntities={['f8dae']} zoomedEntities={['d8bae']}, pannedEntities={['d8bae']} />  

In this snippet, there is no need for a Manipulation API as such - just change the props and the diagram will update accordingly. The IDs given in the props are just IDs for the nodes in the diagram. With some magic the coordinates and zoom level of the node are computed from this ID.

Refactoring

All of the code for zooming and panning to diagram nodes has been moved into the PanZoom component. Furthermore, there is no imperative "Manipulation API" anymore, all updates on handled via React's lifecycle hooks that are triggered on mount and whenever the props or state changes.

Using setState as a "queue"

This was the fun part and the cool part. Since animated pan/zoom changes are asynchronous I needed a way to block either happening if an operation was already performing. But, you also want to still perform the other after the blocking operation completes. For example, suppose you change the pannedEntities prop and then quickly afterwards (or at the same time) the zoomedEntities prop, you'd want to pan first, wait for that to finish, and then zoom. That's where this setState "queue" comes in. In fact, that's a bit misleading since it's not really a traditional job queue but it's similiar(ish).

Here's where the magic happens...

    componentDidUpdate(prevProps, prevState) {
        // Whenever the state or props change, we check if the component should pan or zoom
        // If ONE of them is needed, it is performed
        // By calling setState in the then callback, this will be called again
        // So if both of them are needed, they are performed one after the other
        const { shouldPan, shouldZoom, ready } = this.state;
        if(shouldPan && ready) {
            this.panToEntities().then(() => {
                this.setState({shouldPan: false})
            });
        }
        else if(shouldZoom && ready) {
            this.zoomOnEntities().then(() => {
                this.setState({shouldZoom: false})
            });
        }
    }

In essence, this acts as a queue because calling setState in componentDidUpdate will call componentDidUpdate to happen again. The shouldPan and shouldZoom flags are set in componentDidUpdate like so:

componentWillReceiveProps(nextProps) {  
        const prevProps = this.props;
        const {pannedEntities = [], zoomedEntities = [], diagram} = nextProps;
        if(! isEqual(diagram, prevProps.diagram)) {
            // Do some init stuff
        }

        if(! isEqual(this.state.pannedEntities, pannedEntities) || pannedEntities.length < 1) {
            this.setState({shouldPan: true, pannedEntities: pannedEntities});
        }
        if (! isEqual(this.state.zoomedEntities, zoomedEntities) || zoomedEntities.length < 1) {
            this.setState({shouldZoom: true, zoomedEntities: zoomedEntities});
        }
    }

So whenever the pannedEntities and zoomedEntities props change, so do shouldPan and shouldZoom. Furthermore, both are also set to true whenever the diagram is manually moved by the user since we can no longer be sure that the pannedEntities and zoomedEntities prop maps to the diagram location anymore.

The ready flag is just set whenever the diagram is moving and stops another move being triggered at the same time.

You can see all of the code for this on GitHub and I'm well aware that this is not the best and most generic explanation of "queueing" with setState. When I have time, I would like to write a more generic and widely applicable how-to post on this. In the mean time, just comment if you need some clarification.

Jacob Windsor

Likes creative computer-based stuff. Web, Science, Typography, Graphics... Trying to combine too many interests into something coherent.

Maastricht, Netherlands