Как мы делали график с горизонтальным скроллом на 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 можно посмотреть здесь.

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

ссылка на оригинал статьи https://habr.com/ru/company/simbirsoft/blog/504248/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *