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

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

Reading time 6 min
Views 5.5K

Если в приложении нужно выводить много графических данных, диаграмм, интерактивных виджетов – важно позаботиться о 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 можно посмотреть здесь.


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

Tags:
Hubs:
+7
Comments 2
Comments Comments 2

Articles

Information

Website
www.simbirsoft.com
Registered
Founded
Employees
1,001–5,000 employees
Location
Россия