Swizec Teller - a geek with a hatswizec.com

Senior Mindset Book

Get promoted, earn a bigger salary, work for top companies

Senior Engineer Mindset cover
Learn more

    The two ways to build a zoomable dataviz component with d3.zoom and React

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

    Well, you use d3.zoom. That gives you zoom events for pinch-to-zoom and the mousewheel. 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 datapoints? 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 👇

    Two ways to zoom with React and d3.zoom

    Both scatterplots use the same random data. Left side zooms like an image, right side zooms the space between datapoints. It even works on a phone, look.

    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 🤘

    See the Pen Two different zooms with D3 and React by Swizec Teller (@swizec) on CodePen.

    <Chart /> component talks to d3.zoom

    Our <Chart /> component renders two scatterplots and talks to d3.zoom to zoom them. This way we can use a single zoom behavior for the entire SVG, which makes the scatterplots 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>
            <scatterplot
              data={this.state.data}
              x={width / 2}
              y={0}
              width={width / 2}
              height={height}
              zoomtransform={zoomTransform}
              zoomtype="detail"
            ></scatterplot>
          </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 datapoints and zooms itself

    The <Scatterplot /> component follows the full integration approach I outline in React+D3v4. We have D3 stuff in an updateD3 function 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 scatterplot 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}>)}
          </circle></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, yScale translates 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 datapoints 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 scatterplot's positioning and spacing between datapoints.

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

    get transform also handles zooming. It creates an SVG transform attribute 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 scatterplots 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 play with this example on CodePen.

    See the Pen Two different zooms with D3 and React by Swizec Teller (@swizec) on CodePen.

    To learn more about putting React and D3v4 together, check out my new book, React+D3v4

    Published on August 25th, 2017 in d3js, Front End, react, Technical

    Did you enjoy this article?

    Continue reading about The two ways to build a zoomable dataviz component with d3.zoom and React

    Semantically similar articles hand-picked by GPT-4

    Senior Mindset Book

    Get promoted, earn a bigger salary, work for top companies

    Learn more

    Have a burning question that you think I can answer? Hit me up on twitter and I'll do my best.

    Who am I and who do I help? I'm Swizec Teller and I turn coders into engineers with "Raw and honest from the heart!" writing. No bullshit. Real insights into the career and skills of a modern software engineer.

    Want to become a true senior engineer? Take ownership, have autonomy, and be a force multiplier on your team. The Senior Engineer Mindset ebook can help 👉 swizec.com/senior-mindset. These are the shifts in mindset that unlocked my career.

    Curious about Serverless and the modern backend? Check out Serverless Handbook, for frontend engineers 👉 ServerlessHandbook.dev

    Want to Stop copy pasting D3 examples and create data visualizations of your own? Learn how to build scalable dataviz React components your whole team can understand with React for Data Visualization

    Want to get my best emails on JavaScript, React, Serverless, Fullstack Web, or Indie Hacking? Check out swizec.com/collections

    Did someone amazing share this letter with you? Wonderful! You can sign up for my weekly letters for software engineers on their path to greatness, here: swizec.com/blog

    Want to brush up on your modern JavaScript syntax? Check out my interactive cheatsheet: es6cheatsheet.com

    By the way, just in case no one has told you it yet today: I love and appreciate you for who you are ❤️

    Created by Swizec with ❤️