Custom Data Visualizations Using D3 and React - Part 2

By Kristy Royal

Part 2 - D3 and react: custom data visualization   Did you miss the first blog in our Custom Data Visualization series? Check it out here. Or,  if you prefer to skip to the end to see the code, just click here.

Challenge #2: Multiple data values

    The second challenge is the data visualization I needed to create had multiple y-axis values. All of the examples and tutorials I found only had one y-axis value, and used very simple data sets. If you are familiar with data visualization best practices, there’s a bit of a debate on whether two y axes are readable and effective. The folks at Datawrapper have a hard and fast rule to not have a dual axis, because studies show they can be misleading with the relationship between two data series. For this particular visualization, we are comparing quantity versus cost (the two y axes), over time (the x axis).

Dual y axes can also sometimes be difficult to interpret. However, this particular client is used to working with large datasets, and needed to be able to compare two different values over time. According to Infogram, as long as you have a bar chart or histogram (quantity) paired with a line graph (cost), you can create an easy to interpret data visualization. The y-axes are related, as they’re showing the cost vs quantity of a product over time, and we’re using contrasting colors (blue and orange), so that the bar and line graphs are easily viewed, even for color-blind people.

This particular data visualization had icons for various categories of events above the main data visualization. These icons needed to appear in the exact place along the x-axis as the corresponding data for the line graph. D3 has an easy way to create a line graph, but not an easy way to create a line graph where the x-axis needs to be re-used. So, I had to create my own line graph using paths. So, with the bar graph, the line graph, and the icons, there are three pieces of data that need to correspond perfectly along the x-axis.

Using animations in your data visualization

D3 and react data visualization series

  I also included a quick animation as the data visualization loads onto the page. Animations not only delight the user, they also provide a focal point for the user as the data visualization is loaded onto the page. However, animations can be tricky. If they’re too fast, it can be jarring, difficult for the user to comprehend, and it can seem like a mistake. If they’re too slow, you run the risk of frustrating the user with a gratuitous animation (i.e., it’s no longer fun), and it can seem like there’s an error loading the data visualization. Animations shouldn’t last longer than 300 ms, according to Material Design. If your data visualization can’t load smoothly with an animation 300 ms or less, then perhaps you should have a less complex visualization, or skip the animation on load. Complex visualizations easily lend themselves to smaller animations within the visualization, and animation in the full animation isn’t necessary, if it’s too complex to smoothly animate in 300 ms or less.

Adding tooltips on hover

  D3 makes it really easy to add any SVG elements to your data visualization. However, adding an SVG element as a tooltip on hover can be very tricky, especially if you try to center text within that element. Instead, I opted to recreate the elements of a tooltip on hover with D3. They allow you to create html elements, and append a style to those elements. I created a div filled with black, 80% opacity, 10px padding, white text with a description of the data on hover. By using D3 to create the tooltip, it was easy to flow the data in without any lag, or calling the data a second time, since it’s already passed through.

Code used in D3:

const draw = () => {
    const starterWidth = wrapperRef.current.offsetWidth;
    const svgWidth = starterWidth - props.paddingLeft - props.paddingRight;

    const graphWidth = svgWidth - props.yAxisLeftWidth - props.yAxisRightWidth;

    props.data.forEach(d => (d.date = new Date(d.date)));
    const xMin = d3.min(props.data, d => d.date);
    const xMax = d3.max(props.data, d => d.date);
    const xExtent = [xMin, xMax];
    const yExtent = d3.extent(props.data, d => d.bar);
    const y2Extent = d3.extent(props.data, d => d.line);

    const xScale = d3
         .scaleTime()
         .domain(xExtent)
         .range([0, graphWidth])
         .nice();

    const yScale = d3
         .scaleLinear()
         .domain(yExtent)
         .range([props.graphHeight, 0])
         .nice();

    const xAxis = d3
         .axisBottom(xScale)
         .tickFormat(d3.timeFormat(props.xAxisDateFormat));

     const yAxis = d3
         .axisLeft(yScale)
         .tickFormat(
         d =>
             `${props.yLeftLabelBefore}${formatyLeftAxis(d)}${
             props.yLeftLabelAfter}`
         );
     const y2Scale = d3
         .scaleLinear()
         .domain(y2Extent)
         .range([props.graphHeight, 0])
         .nice();

     const y2Axis = d3
         .axisRight(y2Scale)
         .tickFormat(
             d =>
                 `${props.yRightLabelBefore}${formatyRightAxis(d)}${
                 props.yRightLabelAfter}`
         );

     const line = d3
         .line()
         .x((d, index) => index * 5)
         .y(d => y2Scale(d.line));

     var newLine = line(props.data);

     d3.select(".x-axis").call(xAxis);

     d3.select(".y-axis")
         .call(yAxis)
         .call(g => g.selectAll(".tick text").attr("fill",        props.barLegendColor))
         .call(g => g.selectAll("line").attr("stroke", props.barLegendColor))
         .call(g => g.selectAll("path").attr("stroke", props.barLegendColor));
     d3.select(".y2-axis")
         .attr(
             "transform",
             `translate(${graphWidth + props.yAxisLeftWidth} ${
                  props.iconSectionHeight
              })`
          )
         .call(y2Axis)
         .call(g => g.selectAll(".tick text").attr("fill", props.lineLegendColor))
         .call(g => g.selectAll("line").attr("stroke", props.lineLegendColor))
         .call(g => g.selectAll("path").attr("stroke", props.lineLegendColor));

     d3.select(svgRef.current)
         .select("#graphSvg")
         .attr("width", graphWidth a+ props.yAxisLeftWidth);

     d3.select(svgRef.current).attr("width", svgWidth);

     d3.select(svgRef.current)
         .select("#axes")
         .attr("width", svgWidth);

     d3.select(svgRef.current)
         .selectAll(".barElement")
         .data(props.data)
         .transition()
         .duration(300)
         .ease(d3.easeLinear)
         .attr("height", 0)
         .attr("y", props.graphHeight + props.iconSectionHeight)
         .transition()
         .duration(300)
         .attr("x", d => xScale(d.date))
         .attr("y", d => yScale(d.bar))
         .attr("width", (graphWidth / props.data.length) * props.barWidth)
         .attr("height", d => props.graphHeight - yScale(d.bar))
         .style("fill", props.barColor);

     d3.select(svgRef.current)
         .selectAll(".icons")
         .data(props.data)

                 .append("svg")
         .transition()
         .duration(300)
         .ease(d3.easeLinear)
         .attr("height", 0)
         .attr("width", 30)
         .attr("x", 0)

         .transition()
         .duration(300)
         .attr("height", 50)
         .attr("y", `-${props.iconSectionHeight}`)
         .attr("x", d => xScale(d.date));

     d3.select(svgRef.current)
         .selectAll(".icons")
         .selectAll("svg")

         .attr("x", d => xScale(d.date))
         .attr("y", `-${props.iconSectionHeight}`)
         .append("path")
         .style("fill", grey[500])
         .attr("d", d => {
         switch (d.icon) {
             //put all possible versions of your icon categories here
             default:
             return "";
         }
     });

     //tooltip for icons
     d3.select(svgRef.current)
         .selectAll(".icons")
         .select("svg")
         .on("mouseover", (d, index) => {
         const [x, y] = [xScale(d.date), -50];

         d3.selectAll("#svgWrapper")
             .append("div")
                 .attr(
                 "style",
                 `position: absolute; background: ${grey[900]};
             padding: 5px; opacity: 0.9; color: white;
             text-align: center; top: ${y}px; left: ${x}px; border-radius: 4px;`
             )
             .attr("id", "iconTip" + index)
             .html(
                 ` <span><strong>${formatDate(d.date)}</strong><br>${
                 d.iconDescription
                  }</span>`
              );
          })
         .on("mouseout", (d, index) => {
             d3.select("#iconTip" + index).remove();
         });

     d3.select(svgRef.current)
         .selectAll(".icons")
         .selectAll("svg")
         .append("path")
         .attr("fill", grey[500])

         .attr("d", d => {
             switch (d.iconDirection) {
                             case "up":
                     return
"M9.22.32.32,9.22a1.1,1.1,0,0,0,0,1.56l1,1a1.11,1.11,0,0,0,1.55,0L10,4.76l7.09,7.06a1.11,1.11,0,0,0,1.55,0l1-1a1.1,1.1,0,0,0,0-1.56L10.78.32a1.1,1.1,0,0,0-1.56,0Z";
                         case "down":
                     return "M9.22,11.82.32,2.91a1.09,1.09,0,0,1,0-1.55l1-1a1.09,1.09,0,0,1,1.55,0L10,7.37l7.09-7a1.09,1.09,0,0,1,1.55,0l1,1a1.09,1.09,0,0,1,0,1.55l-8.9,8.91a1.12,1.12,0,0,1-1.56,0Z";
                 default:
                     return "";
             }
         })
         .attr("transform", "translate(5,35)");

     //invisible rectangle for tooltip so it doesn't blink when there's a gap in the visible bars,
     //and so that it has a tooltip when mouse on an empty space (i.e. when bar is very low)
     d3.select(svgRef.current)
         .selectAll(".invisibleRect")
         .data(props.data)
         .attr("fill", "rgba(0,0,0,0)")
         .style("opacity", "0")
         .attr("x", d => xScale(d.date))
         .attr("y", "0")
         .attr("height", props.graphHeight)
         .attr("width", graphWidth / props.data.length);

     //adds a full height invisible rectangle to the bar chart, so if the user hovers over an empty place on the timeline, the hover effect will still work
     d3.select(svgRef.current)
         .selectAll(".invisibleRect")
         .on("mouseover", (d, index) => {
         const [x, y] = [xScale(d.date), props.iconSectionHeight];
         d3.selectAll("#svgWrapper")
             .append("div")
             .attr(
             "style",
                 `position: absolute; background: ${grey[900]};
             padding: 5px; opacity: 0.9; color: white; text-align: center; top: ${y}px; left: ${x}px; border-radius: 4px;`
         )
         .attr("id", "barTip" + index)
         .html(
             ` <span><strong>${formatDate(
                 d.date
             )}</strong><br>${props.barLabelBefore +
         formatBarLabel(d.bar) +
         props.barLabelAfter}
${props.lineLabelBefore +
              formatLineLabel(d.line) +
             props.lineLabelAfter}</span>`
         );
     })
         .on("mouseout", (d, index) => {
         d3.select("#barTip" + index).remove();
     });

     d3.select(svgRef.current)
         .selectAll("line")
          .data(newLine)
         .transition()
         .duration(300)
         .ease(d3.easeLinear)
         .attr("x1", 0)
         .attr("x2", 0)
         .attr("y1", 0)
         .attr("y2", 0)
         .transition()
         .duration(300)

     .attr("x1", (d, index) =>
         index < props.data.length - 1 ? xScale(props.data[index].date) : "0"
     )
     .attr("y1", (d, index) =>
         index < props.data.length - 1 ? y2Scale(props.data[index].line) : "0"
     )
     .attr("x2", (d, index) =>
         index < props.data.length - 1 ? xScale(props.data[index + 1].date) : "0"
     )
     .attr("y2", (d, index) =>
         index < props.data.length - 1
             ? y2Scale(props.data[index + 1].line)
             : "0"
         )
     .attr("stroke-width", props.lineWidth)
     .style("stroke", props.lineColor);
};

      Stay tuned for part 3 of this series, coming soon and don't forget to subscribe  to our Newsletter for even more data visualization tips and UX trends.

New call-to-action