Если в приложении нужно выводить много графических данных, диаграмм, интерактивных виджетов – важно позаботиться о 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 мы и будем реализовывать горизонтальную прокрутку: эта функция навешивает на элемент обработчики событий для реализации зумирования и dragn
drop.
Вызов 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/
Добавить комментарий