Quick update - the code in this article was used as the starting point for components in the d3fc project. It’s a bit more advanced than this article, but you might find it interesting to see what this code evolved into!
Recently I’ve been looking at various D3 components, which has been a fun project. I haven’t yet had the chance to develop an interactive, dynamic component though, which has meant that the resulting charts have been sadly static. For this article I wanted to use what I’ve learned to build a fully interactive chart - something that wouldn’t look out of place on a financial app.
Here’s the chart we’re going to build:
Let’s take a moment to go over this chart’s interactive features (which work equally well whether you’re using a mouse or touch gestures).
The lower chart, in grey, is what I’m going to call the navigation chart. It shows the full extent of the data set. The darker grey box (which I’m calling the viewport) shows the current view - ie, what’s currently being shown in the main chart - which means that you can see at a glance the relationship between the part of the data you’re looking at and the rest of the dataset. You can do quite a few useful things with this box:
- Drag it left / right to scroll through the data on the main chart
- Click and drag the left and right edges to increase / decrease the amount of data shown
- Click off it to get rid of it, and so show the full data set in the main chart
- Click off it and drag to create a new box
The main chart is also interactive. You can:
- Click and drag the chart data left or right (panning)
- Use the mouse wheel to zoom in or out
As you can see, the navigation viewport automatically adjusts its size and position to match the data shown in the main chart when you pan or zoom.
Note: As with my previous D3 posts, my starting point for this work was the chart Tom developed in his article on OHLC and candlestick components - you’ll see that I’m using his candlestick chart here.
Putting it Together
Although there’s not a huge amount of code for this project, it’d be rather a lot to digest all in one go, so let’s put the chart together bit by bit, and I’ll explain everything as I go.
Setting up the data
The first thing we do is to set up the data we’re going to show on our chart. We’re using randomly generated time series data, and we’re mimicking real-world financial data by only having data points for weekdays.
We also do a bit of data caching, calculating the minimum and maximum X and Y values we have. You probably wouldn’t want to do this in the wild (especially if your data was coming in dynamically) but for our purposes it’ll make the code a little easier to read.
The main chart
The next step is to build the main chart. We start by defining the area in which we’re going to show the chart.
Now we create the X and Y scales we’re going to use for the data (we’ll also be using xScale
a lot when we add the interactive components).
We now define the X and Y axes and draw them on the chart.
Finally, we define the data series that we’re going to show (a candlestick series in this case) and draw it on the chart.
That’s the main chart done!
The lower chart
Let’s turn to the navigation chart. As before, we start by setting up the drawing area.
Now we define the X and Y scales.
For the navigation chart, we only want an X axis, so we define and add that now.
The only thing left to do is to add the data. We’re going to use a d3.svg.area
component, but we’re also going to add a d3.svg.line
component so we can add a little visual punch.
We style the area component with no borders, and we style the line component to be slightly darker in colour than the area.
Now that’s done, we have two charts, one above the other. Nothing interactive so far though - that comes next!
The viewport on the lower chart
To create our viewport we’re going to use a D3 component called a brush
. The brush component automatically handles all of the behaviour I’ve noted above: you can drag it, resize it, and click off it to clear it.
The brush component provides three events:
brush
, which is called every time the brush’s dimensions or position changebrushstart
, which is called when the brush’s dimensions or position are about to changebrushend
, which is called when the user stops interacting with the brush
For this chart, we’re going to use the brush
event to update the xScale.domain
and redraw the main chart.
The method we’re calling there, redrawChart()
, simply calls the data series and the X axis in order to redraw them following a change in the X scale. I’ve separated it out into its own method because we’re going to need to call it from a few other places once we add panning and zooming.
Then we add the viewport component to the navigation chart.
For styling, I’m making the brush translucent with a dark border, to make it stand out from the navigation chart but still obviously be part of it.
By this point we have a fully functional navigation chart - which is pretty impressive, given that the brush code is pleasingly simple.
Zooming and panning on the main chart
The last thing to be added is the ability to zoom into / out of the main chart using the mouse wheel, and the ability to pan left and right by dragging. This is going to be a little more complicated for the following reasons:
- We don’t want to let the user pan past the beginning or end of the data
- We need to update the navigation chart’s viewport when we change the data shown in the main chart
Although you might expect to need another brush
for this, we’re going to use a D3 behaviour called zoom
. This behaviour allows for zooming in and out (as you’d expect given its name) but also provides panning, which I have to admit I hadn’t expected.
To use a zoom
behaviour, we tell it about the main chart’s X scale (we could also give it a Y scale if we wanted to allow interaction in that dimension as well, but we won’t do that here as it doesn’t really make sense in this context). It provides three events: zoom
, zoomstart
and zoomend
; here we’re going to handle thezoom
event, which is called whenever the chart is zooming in, zooming out, or panning in any direction.
The first bit of the event handler looks daunting at first glance, but all it’s doing is making sure we can’t pan past the start or end of the data. If the pan or zoom has caused the domain to move before the start of the data, we use the translate
property to keep it at minDate
; similarly, if it goes past the end of the data, we pin it to maxDate
.
We’re calling redrawChart()
again here, to update the chart and its X-axis, but we’ve also introduced a new method, updateViewportFromChart()
, which will take the new dimensions of the main chart and apply them to the viewport on the navigation chart. In this way we can start to keep the two charts synchronised.
If we to leave things at that, and add this behaviour to the data series, we’ll run across an immediate issue - we’ll only be able to pan and zoom when the mouse is directly over one of our candlesticks! To get around that, we can add a transparent overlay onto the main chart area, and add the behaviour to it, like so:
Then we just have to remember to make it transparent in the CSS:
Don’t do what I did initially and make it white rather than transparent - you’ll kick yourself as you realise the reason why your chart’s suddenly disappeared.
We have one final task if we want to keep our two charts synchronised. We’ve already ensured that the viewport is updated when the zoom changes, now we need to ensure the reverse is also handled - the zoom must be updated when the viewport changes.
To accomplish this, I’m hooking into one of the other brush
events, brushend
.
The updateZoomFromChart()
method needs a bit of explanation, even though it’s pretty short.
Firstly, and most importantly, we need to rebind the main chart’s X-scale to the zoom behaviour. It’s not an obvious step - and it’s one that confused me for quite a while - but without it the zooming and panning will never take account of changes in the main chart which are due to the viewport moving. This is because the zoom behaviour doesn’t contain a reference to the X-scale object - it takes a copy.
The rest of this method handles zooming. There are two properties which control zooming - scale
and scaleExtent
:
scale
specifies how far to zoom in or out - it’s essentially the magnification factorscaleExtent
takes (or returns) a two-value array, which specify the minimum and maximum values that will be honoured for thescale
property.
Values for these properties assume that the domain at the point when the scale is bound has a scale
of 1 - the scale value for the entire domain, then, is the ratio of the current domain to the full domain. If that’s difficult to visualise, consider this example: if we’re currently looking at half of the total range of data, and that’s a magnification factor of 1, the magnification factor for the entire dataset would be 0.5.
We set the minimum scale to be the magnification factor for the full dataset, and we set the maximum scale to be 20 times that - so, no matter what the current zoom scale is, we can always zoom out to see the full dataset, and we can always zoom in to see 1/20th of it.
Now that we’ve done that, the two charts are always fully synchronised.
Final steps
There’s one last thing I’d like to do with this chart: when it’s loaded I’d like to see the last month or so of data by default. That’s pretty simple to accomplish - we just have to modify the main chart’s X-scale domain, and then update everything accordingly.
Done!
Conclusion
We set out to create a simple interactive chart, and that’s exactly what we’ve accomplished. The chart offers lots of ways to interact with it, either by mouse or touch gestures, and with the navigation chart you can quickly see the relationship between the data shown in the main chart and the entire dataset.