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