Understanding d3 v4 ZoomBehavior in the Pan and Zoom Minimap
It has been a while since I posted any new articles. I’ve been working non-stop with Angular 4 (formerly Angular 2), Typescript, RxJS and d3 v4. I recently needed to add a minimap for a visualization and I decided to update my old minimap demo to make us of d3 version 4. While researching the updates to the zooming behavior, I came across this post on GitHub where another developer user was trying to do the same thing (funny enough, also using my previous example). After reading Mike’s response about cross-linking the zoom listeners, I decided to give it a try. While implementing his suggestion, I learned quite a bit about how the new and improved zoom functionality works and I wanted to share that in this post.
My previous example did not make good use of the capabilities of d3’s ZoomBehavior. I was manually updating the transforms for the elements based on zoom events as well as directly inspecting the attributes of related elements. With the latest updates to d3 v4, creating this type of minimap functionality ended being really simple and I was able to remove a lot of code from the old demo. I found that, while the release notes and the docs for the zoom feature are helpful, actually reading through the source is also enlightening. I was eventually able to boil it down to some basic bullet points.
- The is a canvas component (the visualization) and a minimap component. I will refer to these as counterparts, with each making updates to the other. The canvas has a viewport (the innerwrapper element in my example) and a visualization (the pancanvas in my example). The minimap also has a viewport (the frame) and a miniature version of the visualization (the container). As the visualization is scaled and translated, the relative size and scale is portrayed in the minimap.
- The d3 v4 updated demo has 2 zoomHandler methods that each react separately to ‘local’ zoom events. One makes changes to the main visualization’s transform and scale. The other does the same to the minimap’s frame.
- There are also 2 ‘update’ methods. These are called by the zoomHandler in the counterpart component. Each update method will then call the local zoomHandler on behalf of the counterpart. This effectively convey’s the ZoomBehavior changes made in one counterpart over to the other component.
- There will continue to be separate logic for updating the miniature version of the base visualization over to the minimap. (found in the minimap’s render() method)
- There will continue to be separate logic to sync the size of the visualization with the size of the frame. (also found in the minimap’s render() method)
Zoom Behavior notes
- The ZoomBehavior is applied to an element, but that does not mean it will zoom THAT element. It simply hooks up listeners to that element upon which it is called/applied and gives you a place to react to those events (the zoomHandler that is listening for the “zoom” event).
- The actual manipulation of the elements on the screen will take place in the zoomHandler which receives a d3.event.transform value (which is of type ZoomEvent for those of you using Typescript). That event provides you with the details about what just happened (i.e. the transformation and scale that the user just performed on the ZoomBehavior’s target). At this point, you have to decide what to do with that information, such as applying that transformation/scaling to an element. Again, that element does not have to be the same element that the ZoomBehavior was originally applied to (as is the case here).
- We have to add a filtering if() check within each zoom handler to avoid creating an infinite loop. More on that in a in the next section…..
- We apply the ZoomBehavior on two elements (using <element>.call(zoom)). The InnerWrapper of the visualization’s canvas and the Container of the minimap. They will listen for user actions and report back using the zoomHandler.
- Once the zoomHandler is called, we will take the d3.event.transform information it received and update some other element. In this demo, the InnerWrapper’s zoom events are applied to the PanCanvas, while the minimap’s Container events are used to transform the minimap’s Frame element.
- Once each zoom handler has transformed it’s own local target, it then examines the event to see if it originated from its own local ZoomBehavior. If it did, then the logic executes an ‘update()’ call over on its counterpart so that it can also be modified. So we get a sequence like this: “InnerWrapper(ZoomBehavior) –> zoomHandler(in Canvas component) –> updates PanCanvas element –> did zoom event occur locally? –> if so, update minimap”. And from the other side we have this: “minimap Container(ZoomBehavior) -> zoomHandler(in minimap) -> updates minimap Frame element -> did zoom event occur locally? -> if so, update visualization”. You can see how this could lead to an infinite loop so the check to see if the event originated locally is vital.
This diagram shows the general structure I’m describing:
So the end result is a “push me/pull you” type of action. Each side makes its own updates and, if necessary, tells the counterpart about those updates. A few other things to point out:
- Because the minimap’s frame (representing the visualization’s viewport) needs to move and resize inverse to the viewport, I modify the transform event within the minimap when they are received and before they are sent out. The encapsulates the inversion logic in one place and puts that burden solely on the minimap component.
- When modifying a transform, ordering matters. Mike Bostock mentions this in the docs, but I still got tripped up by this when my minimap was not quite in sync with the visualization. I had to scale first, then apply the transform.
- Rather than using the old getXYFromTranslate() method that parses the .attr(“transform”) string property off an element, it is much easier to use the method d3.zoomTransform(elementNode) to get this information. (remember, that method works with nodes, not selections)
At this point, the design works. However, there’s another problem waiting for us:
When the user moves an element on one side, the counterpart on the other gets updated. However, when the user goes to move the counterpart element, the element will “jump back” to the last position that it’s own ZoomBehavior triggered. This is because, when the ZoomBehavior on an element contacts its zoomHandler, it stashes the transform data for future reference so it can pick up where it left off on future zoom actions. This ‘stashing’ only happens when the ZoomBehavior is triggered from UI events (user zooming/dragging etc). So when we manually update the PanCanvas in response to something other than the ZoomBehavior’s ‘zoom’ event, the stashing does not occur and the state is lost. To fix this, we must manually stash the latest transform information ourselves when updating the element outside of the ZoomBehavior’s knowledge. There’s another subtle point here that briefly tripped me up: the ZoomBehavior stashes the zoom transform on the element to which it was applied, NOT the element upon which we are acting. So when the ZoomBehavior hears zoom events on the InnerWrapper, it updates the __zoom property on the InnerWrapper. Later on, when the minimap makes an update call back to the visualization, we have to manually update that property on the InnerWrapper, even though we are using that data to transform the PanCanvas in the zoomHandler.
So here is the final interaction:
- User moves the mouse over the PanCanvas
- The ZoomBehavior on the InnerWrapper hears those events and saves that transform data in the __zoom property on the InnerWrapper.
- The ZoomBehavior then emits the ‘zoom’ event which is handled by the local zoomHandler in the visualization (canvas component in the demo)
- The zoomHandler will apply the transform to the PanCanvas to visibly update its appearance in response to the mouse actions from step #1
- The zoomHandler looks at the event and if it determines that it originated locally, it makes an update call over to the minimap so it can be updated
- The minimap’s update handler inverts the transform event data and applies it to the Frame element
- Because the minimap’s updates to the Frame element occurred outside of the minimap’s own ZoomBehavior, we stash the latest transform data for future state reference. Note: we stash that state, not on the Frame element, but on the minimap’s Container element because that is the element to which the minimap’s ZoomBehavior was applied and that is where it will look for previous state when subsequent ZoomBehavior events are fired when the user mouses over the minimap.
- The minimap’s zoomHandler is called by the update method which applies the matching minimap appearance update to the Frame element.
- The minimap’s zoomHandler determines the update event did not come from the local ZoomBehavior and therefore it does not call the update method on the visualization, thus preventing an infinite loop.
Hopefully this will save you some time and help you understand how the d3 v4 ZoomBehavior can be used for functionality such as this demo. 🙂