Understanding d3 v4 ZoomBehavior in the Pan and Zoom Minimap
See the Pen d3 Minimap Pan and Zoom Demo by Bill White (@billdwhite) on CodePen.
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.
Design
- 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…..
Logic Flow
- 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. 🙂
Hey, Bill!
I found the link to your post in the github issue about shared d3.event. And I found your post super useful. Thanks a lot!
What I wanted to do is to have two different graphs update synchronously. So kind of two different graphs with “shared” x axis but different y axises. I came up with an idea of cross linked handlers on my own. However, if I scrolled one graph for a while, then tried to scroll the second one, both graphs would jump.
It turned out that in addition to applying transforms, I also needed to update “__zoom” property, because that’s where d3 stores last zoom state. This bit of knowledge from this post really helped me.
While this solution work, it seems a bit hack-ish. Do you think there might be a cleaner way to achieve the same result?
PS: Once again, thanks for the post! Without it I would’ve spent hours searching for solution.
No, I the the solution you have is the correct one. You might think of it as being the same approach you might take if, instead of a corresponding graph, you instead had a separate slider control that was updating the first graph’s scroll position; if that was your goal, the solution would likely be the same implementation so I would say you’re design is sound. If you do end up coming up with a cleaner way, I’d love to hear about it though. 🙂
Hey Bill, I appreciate you. I’m just wondering if you’re aware of the fact that scrolling inside of the “frame” on the minimap results in some weird results, would you have any suggestions on how to remove that feature?
I’m not seeing any thing strange on my end when scrolling (I’m assuming you mean with the mouse wheel) within the minimap frame. What browser do you have and what are you seeing?
What I meant was when your cursor is inside of the minimap frame(resizable square) and you zoom in it zooms out instead. I’m not sure if it’s supposed to be a feature but it basically does the opposite when you’re inside of tiny frame compared to being outside of it and scrolling.
Hi,
A small bug I noticed. The scale extend of the host does not match the scale extend of the minimap.
Can you provide more detail? I wonder if that’s related to the fact that they are kept in sync and the final step isn’t getting updated when it tries to break the cycle or something like that
For anyone that lands here and is having trouble getting translateExtents working for the mini-map, you may want to refer to my stackoverflow question here: https://stackoverflow.com/questions/50101764/d3-zoom-translateextent-calculation-when-target-smaller-than-container/50132790#50132790
I used Bill’s examples as inspiration for a mini-map in my work project that navigates a very large tree structure but got stuck with restricting the mini-map view representation frame within the bounds of the mini-map.
After a lot of research, reading the d3 source and trial and error I finally arrived at a calculation to translate extents to bind a zoom target inside it’s container when the target is smaller than the container.
Bill, please feel free to use my examples in the codePens to update your own examples with working translate extents for the mini-maps if you like.
Thanks – Chris
Hey @Bill, Thank you for the wonderful work on minimap.
I followed below two links to add Map and its minimap.
1. http://billdwhite.com/2013/11/26/d3-minimap-pan-and-zoom-demo/
2. https://codepen.io/garlicb/pen/JwGeYZ?editors=0010
All things are going well in this, except the infinite loop while dragging on main SVG it keeps on rendering minimap again and gain. Even when I mouse-over anywhere on screen it calls zoomHandler method due to which there is lag in dragging on big SVG images.
Steps to reproduce the issue :
1. Open https://codepen.io/garlicb/pen/JwGeYZ?editors=0010
2. Zoom-In upto 2 or more levels
3. Open Inspect Element and add debug point on zoomHandler method.
4. Drag on main SVG
5. Again move mouse anywhere on the screen it will regulary hit zoomHandler method.
Please suggest some solution.