Over a million developers have joined DZone.
{{announcement.body}}
{{announcement.title}}

The Two Ways to Build a Zoomable Dataviz Component With d3.zoom and React

DZone's Guide to

The Two Ways to Build a Zoomable Dataviz Component With d3.zoom and React

Learn how to make your application more interactive and dynamic by using the D3.js library to add zoomable functionality to your app.

· Web Dev Zone
Free Resource

Get deep insight into Node.js applications with real-time metrics, CPU profiling, and heap snapshots with N|Solid from NodeSource. Learn more.

A question I often get is: “How do you build a zoomable dataviz component?”

Well, you use d3.zoom. That gives you zoom events for pinch-to-zoom and the mouse wheel. Detects panning too. Just like your users expect from everything else that zooms.

Then what?

Then you have a choice to make. Do you want to zoom your whole component like it was an image, or do you want to zoom the space between your data points? The first looks pretty, the second gives users a chance to see more detail.

In a side-by-side comparison, the two zoom effects look like this.

Image title

Both scatter plots use the same random data. Left side zooms like an image, right side zooms the space between data points. It even works on a phone.

So how do you make that?

You’ll need:
– 2 React components
– 2 D3 scales
– 1 D3 zoom
– 1 D3 random number generator
– 1 line of HTML
– 5 lines of CSS
– some event hooks
– a sprinkle of state
– a few props

Here we go!

<chart /> Component Talks to D3.zoom

Our <Chart /> component renders two scatter plots and talks to d3.zoom to zoom them. This way we can use a single zoom behavior for the entire SVG, which makes the scatter plots zoom in unison.

I also found it more reliable than attaching d3.zoom to individual <g> elements, but couldn’t figure out why. I think it assumes internally that it’s working on a whole SVG element.

const random = d3.randomNormal(5, 1);
class Chart extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      data: d3.range(200).map(_ => [random(), random()]),
      zoomTransform: null
    }
    this.zoom = d3.zoom()
                  .scaleExtent([-5, 5])
                  .translateExtent([[-100, -100], [props.width+100, props.height+100]])
                  .extent([[-100, -100], [props.width+100, props.height+100]])
                  .on("zoom", this.zoomed.bind(this))
  }
  componentDidMount() {
    d3.select(this.refs.svg)
      .call(this.zoom)
  }
  componentDidUpdate() {
    d3.select(this.refs.svg)
      .call(this.zoom)
  }
  zoomed() {
    this.setState({ 
      zoomTransform: d3.event.transform
    });
  }
  render() {
    const { zoomTransform } = this.state,
          { width, height } = this.props;

    return (
      <svg width={width} height={height} ref="svg">
        <Scatterplot data={this.state.data}
                     x={0} y={0} 
                     width={width/2}
                     height={height}
                     zoomTransform={zoomTransform}
                     zoomType="scale" />
        <Scatterplot data={this.state.data}
                     x={width/2} y={0}
                     width={width/2}
                     height={height}
                     zoomTransform={zoomTransform}
                     zoomType="detail" />
      </svg>
    )
  }
}

Our chart component breaks down into 4 parts:

  1. We use the constructor to generate random [x, y] coordinate pairs and a d3.zoom behavior. scaleExtent defines min and max scaling factor – from -5 to 5 – and translateExtent and extent define movement boundaries. How much do we allow our chart to move around while zooming? We use 100px in every direction.
  2. In componentDidMount and componentDidUpdate, we call our zoom behavior on the rendered SVG. This attaches touch, drag, and scroll events to the DOM. D3 normalizes them into a single zoom event for us.
  3. The zoomed function is our zoom event callback. We update component state with d3.event.transform, which is where D3 puts the information we need to zoom our chart.
  4. Our render method draws two <Scatterplot /> components inside an <svg> element and gives them some props.

<scatterplot /> Component Draws Data Points and Zooms Itself

The <Scatterplot /> component follows the full integration approach I outline in React+D3v4. We have D3 stuff in an updateD3function and we call it when props change to update the internal states of D3 objects.

One complication we run into is that we use the same scatter plot component for two different types of zoom. That means some bloat, but it’s manageable.

class Scatterplot extends React.Component {
  constructor(props) {
    super(props);
    this.updateD3(props);
  }
  componentWillUpdate(nextProps) {
    this.updateD3(nextProps);
  }
  updateD3(props) {
    const { data, width, height, zoomTransform, zoomType } = props;

    this.xScale = d3.scaleLinear()
                    .domain([0, d3.max(data, ([x, y]) => x)])
                    .range([0, width]),
    this.yScale = d3.scaleLinear()
                    .domain([0, d3.max(data, ([x, y]) => y)])
                    .range([0, height]);

    if (zoomTransform && zoomType === "detail") {
      this.xScale.domain(zoomTransform.rescaleX(this.xScale).domain());
      this.yScale.domain(zoomTransform.rescaleY(this.yScale).domain());
    }
  }
  get transform() {
    const { x, y, zoomTransform, zoomType } = this.props;
    let transform = "";

    if (zoomTransform && zoomType === "scale") {
      transform = `translate(${x + zoomTransform.x}, ${y + zoomTransform.y}) scale(${zoomTransform.k})`;
    }else{
      transform = `translate(${x}, ${y})`;
    }

    return transform;
  }
  render() {
    const { data } = this.props;    

    return (
      <g transform={this.transform} ref="scatterplot">
        {data.map(([x, y]) => <circle cx={this.xScale(x)} cy={this.yScale(y)} r={4} />)}
      </g>
    )
  }
}

Much like the <Chart /> component, you can think of <Scatterplot /> as having 4 parts:

  1. constructor and componentWillUpdate call updateD3 with fresh props to update internal D3 state.
  2. updateD3 sets up two linear scales for us. xScale translates between data values and horizontal coordinates, yScaletranslates between data values and vertical coordinates.
  3. The third part is split between the bottom of updateD3 and get transform. It handles zooming.

Inside updateD3 we zoom the space between data points by changing our scale’s domains. zoomTransform.rescaleX takes a scale and returns a changed scale. We take its domain and update xScale. Same for yScale. This updates both the scatter plot's positioning and spacing between data points.

This will never make intuitive sense to me, but it works.

get transform also handles zooming. It creates an SVG transformattribute which we use to position and scale a scatterplot. We use translate() to move a chart into position and scale() to make it bigger or smaller depending on the factor zoomTransform gives us.

Even if we’re not zooming, we still translate() the chart so that we can move it around the page and show two scatter plots side by side.

  1. The fourth part is our render method. It creates a grouping element, walks through our data and renders circles.

You can check out the full project on CodePen.

Node.js application metrics sent directly to any statsd-compliant system. Get N|Solid

Topics:
web dev ,react ,d3 ,web application development

Published at DZone with permission of Swizec Teller, DZone MVB. See the original article here.

Opinions expressed by DZone contributors are their own.

{{ parent.title || parent.header.title}}

{{ parent.tldr }}

{{ parent.urlSource.name }}