Use mutability to draw draggable globe on canvas?

Hoping for some feedback regarding the use of mutable state. I’ve been trying to accomplish the following in a notebook:

  • one cell contains a canvas node
  • a second cell draws an orthographic globe to that canvas, using a projection defined in a third cell
  • dragging on the canvas causes the projection to rotate, which in turn causes the second cell to redraw the canvas

I was hoping to avoid making the second cell (which does the rendering) into a render() function, because I want to extend this idea to a more complex notebook with several maps, and I don’t want to have to have to call every render() method when the projection is rotated. Instead I’m hoping to treat the projection as a “model” that owns some state (including rotation) and for each canvas-drawer to observe changes in that state and redraw, without being told to do so by the one who caused the state change.

Here’s a notebook that distills this down into a simple form:

https://beta.observablehq.com/@jake-low/trouble-with-mutable-globe-rotation

The problem is that the globe rotation is “flickery”, which I think is due to an unintended dependency cycle: the drag callbacks both depend on the projection (because they need to know its current rotation) and modify the projection (applying new rotation to it). Adding a layer of indirection by storing the rotation state separately doesn’t help, because the drag callback also depends on projection.invert, and the projection updates when the rotation is changed.

Is there a simple change I can make that would make this example work? Or am I pursuing an inappropriate pattern and shouldn’t use mutability like this?

Thanks!

Not a direct answer to your question, but dragging the globe is about solved by d3-inertia

the solution involves a render() function and either:

  1. a drag behavior that will call render() whenever it changes the projection rotation [ see https://beta.observablehq.com/@fil/using-d3-inertia-with-observable ]
  2. a d3.timer() function will call render() at every frame, independently of any movement (for better performance it could keep a cache of the parameters and avoid rendering when the image wouldn’t change… I haven’t implemented that part yet) [ see https://beta.observablehq.com/@fil/translucent-earth ]

hth

Yes, the problem is a circular relationship between rotation, globe and the drag event handler. Here is a visualization of your notebook’s computation graph:

I’ve colored the mutable assignments in blue; since references to mutables are not reactive, the assignments flow in the opposite direction.

So when you drag, you’ve mutating the value of rotation, which then causes the globe cell to be re-initialized, re-creating your drag event listeners (dragged, etc.) and behavior (rotator). Unfortunately the drag behavior is stateful, so if you throw away the drag behavior and create a new one in the middle of dragging bad things happen.

What you want here is to prevent your drag behavior from being re-initialized during dragging. You probably also want to prevent your canvas (demo) from being re-initialized during dragging; redrawing the existing canvas will be faster, and if I recall correctly, it’s necessary for touch events to preserve the element that received the touchstart.

I’ll cook up a notebook that shows how to wire the graph differently to achieve these goals.

2 Likes

Here’s my take:

The key points are (1) you only need a single mutable for the projection, while the internal state of the drag behavior can be local variables (v0 for the starting Cartesian coordinates, q0 for the starting quaternion, and r0 for the starting Euler angles) and (2) use this to prevent the canvas from being re-initialized when the projection is mutated during drag.

1 Like

That’s awesome! Thanks so much for taking the time to cook up an example (and to diagram what was wrong with the method I was trying).

I wish I could show you something cool I’ve made with that, but I ran into a subsequent issue which I think is a limitation with the current implementation of d3-geo’s Transforms. I’ve opened an issue on the d3-geo repository to discuss it when you have a chance.

Thanks again for your help!

P.S. @Fil d3-inertia looks really cool, I’ll be sure to try it out.

Very interesting project !

To benefit from the interpolation system you could probably use something like

let inverse = d3.geoProjection(function (x, y) {
  return projection.invert([x, y])
}).scale(1).translate([0,0]).precision(0.1);

[EDIT: not sure it would work though]

However, as far I know, d3.geoChamberlin has no closed-form inverse function, so you’ll also need to put in an approximation (see https://github.com/d3/d3-geo-projection/issues/85 and https://bl.ocks.org/Fil/07930ee04d7fbd9e5e3e84e803d35dcf ). I still have the idea to put this directly in d3-geo but it needs more work to make sure convergence is good in all cases.