BookmarkSubscribeRSS Feed

Adding brush and zoom events to a D3.js streamgraph in SAS Visual Analytics with #D3Thursday

Started ‎11-15-2018 by
Modified ‎03-25-2019 by
Views 5,000

This #D3Thursday we will create a D3.js streamgraph with brush and zoom events!

 

StreamgraphStreamgraph

 

To save some time, I will only be going over how to add the brush and zoom events in this post, rather than discussing how to create a streamgraph. Creating a streamgraph is fairly straightforward and takes advantage of a built-in D3.js path builder much like our donut and sunburst charts from earlier weeks. If you have any questions about that process, feel free to check out the source code and send me any questions you have!

 

 

As with previous posts I've included sample data (Sample_Data_11.sas7bdat) for this post to help you get started. While the sample data set isn't ideal for the streamgraph (typically streamgraphs are used to represent data in which one category dominates the y metric at a time before receding), it gives you a starting place to play around with the brush and zoom events! For more information on when and how to use streamgraphs, see the article linked at the bottom of this post!

 

Brush and Zoom Events

 

Using brush and zoom events in combination, we can allow the user to easily zoom in on or select any part of our streamgraph for examination. Luckily, D3.js has built-in tools that make it easy to provide this functionality on any graph!

 

Our Graph Layout

Let's start by looking at the layout of our graph to supply some context for the following code snippets:

<svg>
<defs>
<!-- gradients -->
</defs>
<g class="g-legend" ...>
<!-- legend elements -->
</g>
<text class="x-label" ...></text>
<text class="y-label" ...></text>
<g class="x-axis" ...>
<!-- Primary x axis elements -->
</g>
<g class="x-axis-context" ...>
<!-- Context x axis elements -->
</g>
<g class="y-axis" ...>
<!-- Y axis elements -->
</g>
<g class="g-chart-area" ...>
<!-- Primary chart data path elements -->
</g>
<g class="g-context-area" ...>
<!-- Context chart data path elements -->
</g>
</svg>

The key thing to notice here is that we now essentially have two charts, the primary chart and the context chart. The context chart will remain the same while the primary chart is zoomed in on or brushed across. This way the user never forgets what he has selected and can examine a section of the chart without losing the big picture.

 

The Brush and Zoom Objects

In order to implement the brush and zoom functionality, we will start by creating the brush and zoom objects:

// Create brush for context graph
BRUSH = d3
.brushX()
.extent([[0, 0], [CHART_WIDTH, CONTEXT_HEIGHT]])
.on("brush end", brushed);

// Create zoom for primary graph
ZOOM = d3
.zoom()
.scaleExtent([1, Infinity])
.translateExtent([[0, 0], [CHART_WIDTH, CHART_HEIGHT]])
.extent([[0, 0], [CHART_WIDTH, CHART_HEIGHT]])
.on("zoom", zoomed);

These objects define the areas in which the user can brush or zoom, as well as handle the job of turning mouse events like clicks, drags, and scrolls, into brush and zoom events. The brush part refers to the users ability to drag and move a selection on the context graph at the bottom, while the zoom part refers to the users ability to zoom in on or drag across the main graph at the top.

 

Applying the Objects

These objects, just like D3.js axes, must be bound to an object in order to function. 

// Create zoom rect
ZOOM_RECT = G_CLIP.selectAll(".zoom").data([DATA]);

ZOOM_RECT.enter()
.append("rect")
.classed("zoom", true)
.merge(ZOOM_RECT)
.attr(
"transform",
"translate(" +
(Y_AXIS_WIDTH + Y_AXIS_PAD) +
"," +
(Y_LABEL_HEIGHT + VERT_PAD) +
")"
)
.attr("width", CHART_WIDTH)
.attr("height", CHART_HEIGHT)
.call(ZOOM);

// Create brush group
BRUSH_GROUP = G_CONTEXT_AREA.selectAll("g").data([DATA]);

BRUSH_GROUP.enter()
.append("g")
.classed("brush", true)
.merge(BRUSH_GROUP)
.call(BRUSH)
.call(BRUSH.move, X_SCALE.range());

The zoom object is attached to a rect element that is the same dimensions as our primary chart, while the brush object is attached to a group element that is attached to our context area group. This is because the brush event requires multiple objects to track all the interactions the user has with the brush. It requires a background rect for users to make selections on, a selection rect that can be moved across the background rect, and a rect for the east and west side of the selection so the selection can be expanded from either side.

 

Handling the Events

Now that our objects are applied to the correct elements, all that's left is to handle the events issued by the brush and zoom objects.

function brushed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom") return; // ignore brush-by-zoom
var s = d3.event.selection || X_SCALE_CONTEXT.range(); // use entire range if selection is null

// Update x scale domain of main chart scale of context chart
X_SCALE.domain(s.map(X_SCALE_CONTEXT.invert, X_SCALE_CONTEXT));

// Update x axis of main chart using updated scale
SVG.select(".x-axis").call(d3.axisBottom(X_SCALE).tickSizeOuter(0));

// Update paths of main chart using updated scale
G_CHART_AREA.selectAll("path").attr("d", AREA);

// Update zoom to point to same location as brush
G_CLIP.select(".zoom").call(
ZOOM.transform,
d3.zoomIdentity.scale(CHART_WIDTH / (s[1] - s[0])).translate(-s[0], 0)
);
}

function zoomed() {
if (d3.event.sourceEvent && d3.event.sourceEvent.type === "brush") return; // ignore zoom-by-brush
var t = d3.event.transform;

// Update x scale domain of main chart using scale of context chart
X_SCALE.domain(t.rescaleX(X_SCALE_CONTEXT).domain());

// Update x axis of main chart using updated scale
SVG.select(".x-axis").call(d3.axisBottom(X_SCALE).tickSizeOuter(0));

// Update paths of main chart using updated scale
G_CHART_AREA.selectAll("path").attr("d", AREA);

// Update brush to point to same location as zoom
G_CONTEXT_AREA.select(".brush").call(
BRUSH.move,
X_SCALE.range().map(t.invertX, t)
);
}

Notice that these two functions follow the same set of steps:

  1. Ignore events spawned by the other function
  2. Grab selection or transform from event
  3. Update x scale domain using selection or transform
  4. Update x axis using updated scale
  5. Update paths using updated scale
  6. Update object associated with other function to be consistent with current object

The important part to notice is the first and last step here. The brush and zoom interactions are inherently intertwined, so zooming in requires moving the brush, and vice versa. Then we must also include code to ignore events spawned by the other function, or else we would have an infinite recursion problem.

 

Clipping

With the callback functions implemented, our chart is interacting properly except for one issue:

 Streamgraph Clipping IssueStreamgraph Clipping Issue

 We must clip our paths when they are outside of our chart area! In order to do this we need to perform a slight modification to the layout of our chart.

 

<svg>
<defs>
<!-- gradients -->
<clipPath id="clip">
<rect class="clip-path-rect" ...> </rect>
</clipPath>
</defs>
<g class="g-legend" ...>
<!-- legend elements -->
</g>
<text class="x-label" ...></text>
<text class="y-label" ...></text>
<g class="x-axis" ...>
<!-- Primary x axis elements -->
</g>
<g class="x-axis-context" ...>
<!-- Context x axis elements -->
</g>
<g class="y-axis" ...>
<!-- Y axis elements -->
</g>
<g class="g-clip" clip-path="url(#clip)">
<g class="g-chart-area" ...>
<!-- Primary chart data path elements -->
</g>
<rect class="zoom" ...></rect>
</g>
<g class="g-context-area" ...>
<!-- Context chart data path elements and brush elements-->
</g>
</svg>

There are a few key changes made here that allow us to clip our paths properly: 

  1. A clipPath element with id="clip" is appended to our defs
  2. A rect with the x, y, width, and height of our chart area is appended to our clipPath
  3. Our chart area group is nested within a group with the attribute clip-path="url(#clip)"

Note that we must apply the clipPath to an absolutely positioned element, which is why we created the new g-clip group. If the clipPath is applied to the transformed g-chart-area group then the clipPath will inherit the transform from said group and no longer position as expected. 

 

Additional Resources

Next Post

Next post we will look at how we can animate an area chart over time!

 

Remember to follow these articles on the SAS Communities Library and the #D3Thursday Twitter hashtag. Comment below or Tweet your questions and comments!

 

D3Thursday Logo.png 

Version history
Last update:
‎03-25-2019 10:04 AM
Updated by:
Contributors

sas-innovate-2024.png

Don't miss out on SAS Innovate - Register now for the FREE Livestream!

Can't make it to Vegas? No problem! Watch our general sessions LIVE or on-demand starting April 17th. Hear from SAS execs, best-selling author Adam Grant, Hot Ones host Sean Evans, top tech journalist Kara Swisher, AI expert Cassie Kozyrkov, and the mind-blowing dance crew iLuminate! Plus, get access to over 20 breakout sessions.

 

Register now!

Free course: Data Literacy Essentials

Data Literacy is for all, even absolute beginners. Jump on board with this free e-learning  and boost your career prospects.

Get Started

Article Labels
Article Tags