How we did a chart with a horizontal scroll on d3.js

4 min


If you need to display a lot of graphic data, diagrams, interactive widgets in the application, it is important to take care of the UX so that the user is comfortable to work with. The data output method is especially important if the application is opened on both monitors and smartphones. We share our experience of how we implemented a rather non-trivial solution – custom scrolling using the d3.js data visualization library

Hello, Habr! Today we want to share with you the experience of solving one difficult task using the data visualization library d3.js. First, let’s tell the background.

The project we were working on is an application for monitoring the performance of managers. Its distinctive feature was the presence of many interactive widgets, in particular, graphs.

One of these graphs was a Gantt chart and was supposed to display the duration and date of work shifts of employees on an interval of six months. We needed to display the chart in full size both on mobile devices and on monitors. Due to this requirement from the solution overflow-x: auto I had to refuse: poke the mouse on the scrollbar on the monitor – such a UX. We decided to do a custom scroll. But it turned out that this is not so easy to implement, so we are in a hurry to share our experience with you.

We will show an example implementation on React, but the same can be implemented on any other framework. To work with the schedule, we chose d3.js as a very popular and proven solution. From this library we will need scaling functions for the axes and handlers to determine the scroll. But more about that later, for a start you need to solve the problem with the integration of d3 in React.

The essence of the problem is that d3.js directly manipulates the DOM, which is unacceptable in conjunction with modern frameworks, since they completely assume all the manipulations with the DOM tree and interference with another library in this process will lead to bugs in updating the interface. Therefore, it is necessary to divide their areas of responsibility. We did it this way: React manipulates the DOM, and d3 does the necessary calculations. This integration option was optimal for us, since it allows you to use react optimizations to update the DOM and the usual JSX syntax (you can read about other possible options here). Further examples will show how this is implemented.

Now you can start development!

Basic Scroll Implementation

Let’s start with layout:

We need two axes. For Y, we display the names of employees, for X dates. The block with the X axis and stripes will scroll, they are wrapped in the group tag.

Now we import the necessary functions from d3:

import { event, select } from "d3-selection";
import { zoom, zoomIdentity, zoomTransform } from "d3-zoom";
import { scaleTime } from "d3-scale";

The event and select functions are needed to handle events in the zoom handler and to select dom elements.

Using the zoom function, we will implement horizontal scrolling: this function hangs event handlers on the element to implement zooming and dragndrop.

The zoomTransform call allows you to determine how much the user has shifted the element: each new click begins with the values ​​at which the previous one ended. To reset the coordinates in memory, use zoomIdentity.

The last function, scaleTime, scales the dates on the coordinate axis. Using it, we write the scaling function on the X axis:

export const dateScale = date => {
  const { startDate, endDate, chartWidth } = chartConfig;
  const scale = scaleTime()
    .domain([startDate, endDate])
    .range([0, chartWidth]);
  return scale(date);
};

The time interval is specified in the argument of the domain method: it must be scaled to the axis, the length of which is passed in the argument of the range method.

Now write a zoom event handler. It is in it that scrolling will be implemented.

const onZoom = (scrollGroup, ganttContainer) => {
  const ganttContainerWidth = 
  ganttContainer.getBoundingClientRect().width;
  const marginLeft = yAxisWidth + lineWidth;
  const transform = zoomTransform(scrollGroup.node());
  const maxStartTranslate = chartWidth / 2;
  const maxEndTranslate = ganttContainerWidth - chartWidth / 2 -
  marginLeft;

  transform.x = Math.max(transform.x, maxEndTranslate);
  transform.x = Math.min(transform.x, maxStartTranslate);

  const translateX = defaultTranslate + transform.x;
  scrollGroup.attr("transform", `translate( ${translateX} ,
  0)`);
};

So far, we are only interested in the highlighted lines, since all the “magic” is in them.

First, get the current element offset:

const transform = zoomTransform(scrollGroup.node());

Next, we calculate the new scroll value of the element and pass it to the translate property:

const translateX = defaultTranslate + transform.x;
  scrollGroup.attr("transform", `translate( ${translateX} ,
  0)`);

It remains to connect the zoom environment to the element:

 useEffect(() => {
    const scrollGroup = select(scrollGroupRef.current);
    const ganttContainer = ganttContainerRef.current;

    const d3Zoom = zoom()
      .scaleExtent([1, 1])
      .on("zoom", () => onZoom(scrollGroup, ganttContainer));
    select(ganttContainer)
      .call(d3Zoom);
      select(ganttContainer).call(d3 Zoom.transform,zoomIdentity);

    scrollGroup.attr("transform", `translate(${defaultTranslate} , 0)`);
  });

That’s about it, scrolling works! It remains to add one cool feature and fix one unpleasant bug.

Feature: swipe with two fingers

Let’s start with the features. Macbook or good Windows laptop users know that scrolling horizontally is much more convenient with the touchpad. Swipe left or right with two fingers, and the element scrolls. Our schedule so far does not know how. Teach him!

To do this, add handlers to the mouse wheel event (this is how the browser recognizes this touchpad gesture):

select(ganttContainer)
  .call(d3Zoom)
  .on("wheel.zoom", () => {
    onZoom(scrollGroup, ganttContainer);
  });

const onZoom = (scrollGroup, ganttContainer) => {
   const ganttContainerWidth = ganttContainer.getBoundingClientRect().width;
   const marginLeft = yAxisWidth + lineWidth;
   const transform = zoomTransform(scrollGroup.node());
   const { type, deltaY, wheelDeltaX } = event;
   const maxStartTranslate = chartWidth / 2;
   const maxEndTranslate = ganttContainerWidth - chartWidth / 2 - marginLeft;

   if (type === "wheel") {
     if (deltaY !== 0) return null;
     transform.x += wheelDeltaX;
   }

   transform.x = Math.max(transform.x, maxEndTranslate);
   transform.x = Math.min(transform.x, maxStartTranslate);

   const translateX = defaultTranslate + transform.x;
   scrollGroup.attr("transform", `translate( ${translateX} , 0)`);
 };

Nothing complicated, just add the scroll wheel to transform.x. All! Now the chart can scroll through the gestures of the trackpad.

Bug: touch interception

Now fix the bug. The fact is that our zoom handler intercepts touch events and interprets them as gestures for zooming. Therefore, when a user hits the chart with his finger, he cannot scroll down the site.

To solve this problem, we need to determine the direction of the user’s swipe and, depending on it, include either horizontal scrolling of the chart or vertical scrolling of the page.

First, create the necessary variables:

const scrollXDisabled = useRef(false);
  const startXRef = useRef(0);
  const startYRef = useRef(0);
  const isXPanRef = useRef(false);
  const isYPanRef = useRef(false);

Next, we write a handler for fixing the coordinates of the start of the touch:

const onTouchStart = () => {
    const touch = getTouchObject(event);
    startXRef.current = touch.pageX;
    startYRef.current = touch.pageY;
  };

Now you need to determine the direction of the swipe and enable the desired scroll:

const onTouchMove = () => {
    const touch = getTouchObject(event);
    const diffX = startXRef.current - touch.pageX;
    const diffY = startYRef.current - touch.pageY;

    if (diffX >= 10 || diffX <= -10) {
      isXPanRef.current = true;
    }

    if (diffY >= 3 || diffY <= -3) {
      isYPanRef.current = true;
    }

    if (!isXPanRef.current && isYPanRef.current &&   !scrollXDisabled.current) {
      select(ganttContainerRef.current).on(".zoom", null);
      scrollXDisabled.current = true;
    }
    if (scrollXDisabled) window.scrollBy(0, diffY);
  };

For diffX and diffY, we set a small error so that the handler does not work on the slightest trembling of the finger.

After the user has removed his finger, we return everything to its original state:

const onTouchEnd = zoomBehavior => {
  select(ganttContainerRef.current).call(zoomBehavior);
  scrollXDisabled.current = false;
  isXPanRef.current = false;
  isYPanRef.current = false;
};

It remains to hang our handlers on the zoom environment:

select(ganttContainer)
      .call(d3Zoom)
      .on("touchstart", onTouchStart, true)
      .on("touchmove", onTouchMove, true)
      .on(
        "touchend",
        () => {
          onTouchEnd(d3Zoom);
        },
        true
 );

Done! Now our schedule understands what the user wants to do. A complete code example and implementation of this graph on canvas can be viewed here.

Thank you for the attention! We hope you find this article useful.


0 Comments

Leave a Reply