Как мы делали график с горизонтальным скроллом на d3.js

    Если в приложении нужно выводить много графических данных, диаграмм, интерактивных виджетов – важно позаботиться о UX, чтобы пользователю было удобно работать. Способ вывода данных особенно важен, если приложение открывают и на мониторах, и на смартфонах. Делимся опытом, как мы реализовали достаточно нетривиальное решение – кастомный скролл с помощью библиотеки визуализации данных d3.js.



    Привет, Хабр! Сегодня хотим поделиться с вами опытом решения одной непростой задачи с помощью библиотеки визуализации данных d3.js. Для начала расскажем предысторию.


    Проект, над которым мы работали – приложение для мониторинга эффективности работы менеджеров. Его отличительной особенностью было наличие множества интерактивных виджетов, в частности, графиков.


    Один из таких графиков представлял собой диаграмму Ганта и должен был отображать длительность и дату рабочих смен сотрудников на интервале в полгода. Нам нужно было выводить диаграмму в полном размере как на мобильных устройствах, так и на мониторах. Из-за этого требования от решения overflow-x: auto пришлось отказаться: тыкать мышкой на скроллбар на мониторе – такой себе UX. Решили делать кастомный скролл. Но оказалось, что это не так-то просто реализовать, поэтому спешим поделиться с вами своим опытом.



    Пример: что надо было сделать.


    Мы покажем пример реализации на React, но то же самое можно реализовать на любом другом фреймворке. Для работы с графиком мы выбрали d3.js как очень популярное и проверенное решение. Из этой библиотеки нам понадобятся функции масштабирования для осей и обработчики для определения скролла. Но об этом чуть позже, для начала нужно решить проблему с интеграцией d3 в React.


    Суть проблемы в том, что d3.js напрямую манипулирует DOM, что недопустимо в связке с современными фреймворками, так как они полностью берут на себя все манипуляции с DOM-деревом и вмешательство в этот процесс другой библиотеки приведёт к багам обновления интерфейса. Поэтому нужно разделить их зоны ответственности. Мы сделали это так: React манипулирует DOM, а d3 производит необходимые расчёты. Этот вариант интеграции нам оптимально подошёл, так как он позволяет использовать оптимизации react по обновлению DOM и привычный JSX синтаксис (о других возможных вариантах можно почитать здесь). Далее в примерах покажем, как это реализовывается.


    Теперь можно приступить к разработке!


    Базовая реализация скролла


    Начнём с вёрстки:


    <div ref={ganttContainerRef} className={gantt}>
      <svg className={gantt__chart} ref={svgRef}>
        <g ref={scrollGroupRef}>
          <GanttD3XAxis />
          <GanttD3Bars data={data} />
        </g>
        <GanttD3YAxis data={data} />
      </svg>
    </div>

    Нам нужны две оси. По Y выводим имена сотрудников, по X даты. Скроллиться будет блок с осью X и полосками, они обёрнуты в тег group.


    Теперь импортируем нужные функции из d3:


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

    Функции event и select нужны для обработки событий в обработчике zoom и для выбора dom-элементов.


    С помощью функции zoom мы и будем реализовывать горизонтальную прокрутку: эта функция навешивает на элемент обработчики событий для реализации зумирования и dragndrop.


    Вызов zoomTransform позволяет определить, насколько пользователь сместил элемент: каждый новый клик начинается с тех значений, на которых закончился предыдущий. Чтобы сбросить координаты в памяти, используем zoomIdentity.


    Последняя функция scaleTime масштабирует даты на координатную ось. С её помощью напишем функцию масштабирования на ось X:


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

    В аргументе метода domain указывается временной интервал: его нужно масштабировать на ось, длину которой передаем в аргументе метода range.


    Теперь напишем обработчик события zoom. Именно в нём и будет реализована прокрутка.


    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)`);
    };

    Пока что нас интересуют только выделенные строчки, так как вся «магия» заключается в них.


    Сначала достаем текущее смещение элемента:


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

    Далее вычисляем новое значение прокрутки элемента и передаем его в свойство translate:


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

    Осталось подключить zoom-окружение к элементу:


     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)`);
      });

    На этом практически всё, скролл работает! Осталось добавить одну крутую фичу и устранить один неприятный баг.


    Фича: свайп двумя пальцами


    Начнём с фичи. Пользователи macbook или хороших Windows-ноутбуков знают, что скроллить горизонтально гораздо удобнее с помощью тачпада. Свайпаем двумя пальцами влево или вправо, и элемент прокручивается. Наш график пока так не умеет. Научим его!


    Для этого добавим обработчики на событие колёсика мыши (именно так браузер распознает этот жест тачпада):


    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)`);
     };

    Ничего сложно, просто к прибавляем прокрутку колёсика к transform.x. Всё! Теперь график умеет скроллиться по жестам трекпада.


    Баг: перехват касаний


    Теперь поправим баг. Дело в том, что наш обработчик zoom перехватывает события касания и интерпретирует их как жесты для зуммирования. Поэтому, когда пользователь попадает пальцем на график, то он не может прокручивать сайт вниз.


    Чтобы решить эту проблему, нам нужно определять направление свайпа пользователя и в зависимости от него включать либо горизонтальный скролл графика, либо вертикальную прокрутку страницы.


    Сначала создаём необходимые переменные:


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

    Далее пишем обработчик для фиксирования координат старта касания:


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

    Теперь нужно определить направление свайпа и включить нужную прокрутку:


    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);
      };

    Для diffX и diffY задаём небольшую погрешность, чтобы обработчик не срабатывал на малейшее дрожание пальца.


    После того, как пользователь убрал палец, возвращаем всё в изначальное состояние:


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

    Осталось навесить наши обработчики на zoom-окружение:


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

    Готово! Теперь наш график понимает, что хочет сделать пользователь. Полный пример кода и реализацию этого графика на canvas можно посмотреть здесь.


    Спасибо за внимание! Надеемся, что статья была вам полезна.

    SimbirSoft
    Лидер в разработке современных ИТ-решений на заказ

    Похожие публикации

    Комментарии 2

      0
      Полезная статья, спасибо!
        0
        Благодарим!

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое