Визуализация сложных данных с использованием D3 и React
Существует много возможныx вариантов реализации сложных графиков в ваших проектах. Я за несколько лет попробовал все возможные варианты. Сначала это были готовые библиотеки типа AmCharts 4. AmCharts сразу же оказался большим и неповоротливым. После этого были более гибкие и дружелюбные библиотеки, такие как Recharts. Recharts был поначалу очень хорош, но со временем сложные фичи создавались такими костылями, которые даже показывать стыдно, а какие-то фичи и вовсе были невозможны в реализации. Таким образом, я пришел к D3 и решаю на нем любые задачи, связанные с графиками. Иногда это занимает немного больше времени по сравнению с готовыми инструментами. Но остается одно неоспоримое преимущество — мы всегда знаем, что никогда не упремся в рамки и ваш код не захочется отправить в помойку через пару месяцев.
Какая цель этой статьи? Я хочу рассказать вам про крутой инструмент и о том, как его максимально эффективно использовать в связке с React. Мы последовательно разберем универсальный рецепт для построения компонентов любой сложности.
Сложные данные
Под сложными данными в контексте этой статьи я подразумеваю данные, которые тяжело воспринимать каким-то простым способом (текстом, списком или таблицей). Поэтому для визуального восприятия различной информации мы используем определённые типы графиков.
Пару примеров эффективного восприятия информации:
- если нужно узнать как цифры ведут себя в течение какого-то временного периода, то стоит выбрать линейные или столбчатые диаграммы;
- для акцента на соотношение значения лучше использовать круговые диаграммы;
- если нужно понять как одни показатели влияют на другие, то можно использовать смешанные виды графиков на одной оси;
- и так далее, чего только не бывает.
Что из себя представляет D3
D3.js — это javaScript библиотека для обработки и визуализации данных. Она включает в себя функции для масштабирования, утилиты для манипуляции с данными и DOM-узлами.
При этом, скажу сразу, что большая часть этой библиотеки уже устарела, и ее не стоит использовать. Именно ту часть, где идут манипуляции с DOM узлами, эту задачу мы будем максимально перекладывать на React.
1. Абстрагирование от физических размеров
Я не просто так начинаю с этого пункта. Самое первое, что необходимо сделать, когда приступаем к разработке графика — это абстрагироваться от физических размеров. Ну скажем, чтобы мы могли получать координаты точек и сразу же записывать их в атрибуты. Соответственно, нам нужны какие-то методы, которые получают на вход значение или название категории, а на выходе отдают координаты в пикселях.
getY(`значение`); \\ возвращает координату по оси y в пикселях
getX(`название категории`); \\ возвращает координату по оси x в пикселях
Мы один раз создаем такие функции на один компонент, какой бы он сложный не оказался в итоге. А дальше используем эти функции везде, где нужно создать какой-то элемент, позиция которого зависит от данных.
К счастью в D3 это сделать очень просто.
Получение координат по оси Y (ось значения)
На изображении показано положение точек из массива [4, 15, 28, 35, 40]
в контейнере выстой 300px
:
Теперь посмотрите как с помощью D3 создать функцию для получения физических координат для отрисовки этих точек:
const getY = d3.scaleLinear()
.domain([0, 40])
.range([300, 0]);
Мы создаем функцию getY
с помощью D3 функции scaleLinear()
. В метод domain
передаем область данных, а в range
передаем физические размеры от 300px
до 0px
. Так как в svg отчет начинается с левого верхнего угла, то нужно именно в таком порядке передавать аргументы в range
— сначала 300
, потом 0
.
Мы только один раз работаем с физическими размерами, когда создаем эту функцию и передаем в нее высоту графика. После этого мы работаем только с реальными данными и сразу же выводим полученные размеры в svg атрибуты.
Пример применения функции getY
:
getY(4); // 270
getY(15); // 187.5
getY(28); // 90
getY(35); // 37.5
getY(40); // 0
В качестве аргумента мы передаем значение, а на выходе получаем координату по оси y. Обратите внимание, что это отступ сверху контейнера.
Получение координат по оси X (ось категории)
Аналогичная ситуация по оси X
. Мы хотим один раз подвязаться к категориям, а дальше передавать название категории и получать ее координаты.
На изображении мы видим контейнер шириной 600px
и 5 месяцев. Месяца будут служить подписями по оси X
:
Создадим такую функцию:
const getX = d3.scaleBand()
.domain(['Jan', 'Feb', 'Mar', 'Apr', 'May'])
.range([0, 600]);
Мы используем функцию scaleBand
из D3. В domain
мы передаем все возможные категории в нужном порядке, а в range
область, выделенную под график.
Смотрим пример применения нашей функции getX
:
getX('Jan'); // 0
getX('Feb'); // 120
getX('Mar'); // 240
getX('Apr'); // 360
getX('May'); // 480
В качестве аргумента мы передаем название категории, а на выходе получаем координату по оси X
(отступ слева).
2. Отрисовка простых фигур
С использованием наших функций для получения координат мы уже можем рисовать простые фигуры на координатной плоскости. К простым фигурам в текущем контексте я отношу:
rect
— прямоугольник;circle
— круг;line
— линия;text
— обычный блок текста.
Эти фигуры схожи тем, что они принимают 1 или 2 координаты и просто содержат разные физические свойства (цвет, размер и прочее). Остальные фигуры создаются более сложным путем, об этом позже.
Точки
Для примера попробуем нарисовать точки с использованием svg-фигуры circle
:
const data = [
{ name: 'Jan', value: 40 },
{ name: 'Feb', value: 35 },
{ name: 'Mar', value: 4 },
{ name: 'Apr', value: 28 },
{ name: 'May', value: 15 },
];
return (
);
Фигура circle абсолютно примитивна. В данном случае она принимает координаты центра — cx
, cy
, радиус r
и цвет заливки fill
.
Здесь мы использовали новый метод bandwidth
:
getX.bandwidth()
Данный метод возвращает ширину колонки — расстояние от одного месяца до соседнего. Мы применяем этот метод для того, чтобы сдвинуть наши точки до центра колонки:
getX(item.name) + getX.bandwidth() / 2
Вот, что у нас получится в результате:
Подписи
Для создания текстовых узлов в svg используется фигура text
. Она также принимает координаты и содержит свои личные атрибуты для стилизации.
Подпишем значения на наших точках:
return (
);
Что здесь нового? Мы обернули наш круг и текст элементом g
. Элемент g
один из самых распространенных в svg, обычно он просто группирует элементы и двигает их вместе при необходимости через свойство transform
.
Вот как выглядят наши подписи к точкам:
3. Оси
Для осей существуют готовые элементы в D3.
const getYAxis = ref => {
const yAxis = d3.axisLeft(getY);
d3.select(ref).call(yAxis);
};
const getXAxis = ref => {
const xAxis = d3.axisBottom(getX);
d3.select(ref).call(xAxis);
};
return (
);
Вот что получается, если ничего не менять и не настраивать:
Попробуем добавить немного красоты и переопределим изначальные стили:
const getYAxis = ref => {
const yAxis = d3.axisLeft(getY)
.tickSize(-600) // ширина горизонтальных линий на графике
.tickPadding(7); // отступ значений от самого графика
d3.select(ref).call(yAxis);
};
const getXAxis = ref => {
const xAxis = d3.axisBottom(getX);
d3.select(ref).call(xAxis);
};
return (
);
И немного стилей:
.axis {
color: #ccd6eb;
& text {
color: #666;
}
& .domain {
display: none;
}
}
.xAxis {
& line {
display: none;
}
}
Посмотрим как сейчас выглядит наш пример:
4. Отрисовка сложных фигур
У svg нет каких-то встроенных простых методов для построения кривых по точкам, секций круга и так далее. Это достаточно сложный процесс на низком уровне. D3 предоставляет методы для построения таких сложных фигур.
Кривые линии
Начнем с обычной кривой линии, для которой мы уже построили точки:
const linePath = d3
.line()
.x(d => getX(d.name) + getX.bandwidth() / 2)
.y(d => getY(d.value))
.curve(d3.curveMonotoneX)(data);
// M60,0C100,6.25,140,12.5,180,37.5C220,62.5,260,270,300,270C340,270,380,90,420,90C460,90,500,138.75,540,187.5
В качестве аргумента line()
мы передаем наш массив с данными data
, а D3 уже под капотом проходится по этому массиву и вызывает функции для поиска координат, которые мы передали в методы x
и y
. В curve
мы передаем тип линии, в данном случае это curveNatural
(таких типов достаточно много).
Теперь немного разберем полученную строку. Команда M
используется в строки для указания точки, откуда нужно начать рисовать. Команда С
— это кубическая кривая Безье, которая принимает три набора координат, по которым строит кривую. Подробнее можно почитать здесь — https://developer.mozilla.org/ru/docs/Web/SVG/Tutorial/Paths.
Теперь просто вставляем полученную строку в качестве атрибута d
для элемента path
:
return (
);
Path — одна из самых распространенных фигур в svg из которой можно сделать практически что угодно. Мы еще будем использовать эту фигуру дальше.
Смотрим на результат:
Замкнутые области
Теперь мы попробуем построить замкнутую области с одной кривой стороной. Она будет использоваться в качестве заливки для графика.
В построении области с кривой стороной похожая ситуация, как и с кривой линией. Здесь используется функция area
, а методов становится больше, потому что нужно передать отдельно функцию для поиска нижней линии. Если нам нужна прямая нижняя линия, то просто передаем нулевое значение по низу.
const areaPath = d3.area()
.x(d => getX(d.name) + getX.bandwidth() / 2)
.y0(d => getY(d.value))
.y1(() => getY(0))
.curve(d3.curveMonotoneX)(data);
// M60,300C100,300,140,300,180,300C220,300,260,300,300,300C340,300,380,300,420,300C460,300,500,300,540,300L540,187.5C500,138.75,460,90,420,90C380,90,340,270,300,270C260,270,220,62.5,180,37.5C140,12.5,100,6.25,60,0Z
На выходе также получаем путь, который нужно передать в фигуру path
. Здесь в конце пути появляется новая команда Z
, которая замыкает контур, рисуя прямую линию от текущего положения обратно к первой точке пути. А также в середине строки есть команда L
, которая рисует прямую линию от текущей точки.
Добавляем полученную строку в path
:
return (
);
Смотрим на нашу красоту:
5. События
Мы игнорируем все методы для навешивания событий из D3. Эту задачу мы также перекладываем на React и вешаем все события прям в разметке JSX. А для хранения состояний используем знакомый всем хук useState.
Эффект наведения
Подробнее рассмотрим эффект наведения, остальные события делаются аналогично.
Наша задача сделать эффект увеличения точки при наведении на всю область категории. Так как у нас нет определенного прямоугольника в DOM, на которое можно повесить событие напрямую, то мы будем вешать событие на всю svg, а затем вычислять позицию.
Но для начало заведем состояние активной категории:
// null – если ничего не активно (по умолчанию)
const [activeIndex, setActiveIndex] = useState(null);
После этого пишем наш обработчик:
const handleMouseMove = (e) => {
const x = e.nativeEvent.offsetX; // количество пикселей от левого края svg
const index = Math.floor(x / getX.step()); // делим количество пикселей на ширину одной колонки и получаем индекс
setActiveIndex(index); // обновляем наше состояние
};
return (
)
И добавим событие, которое будет сбрасывать активный индекс, когда мы убираем мышку с svg:
const handleMouseMove = (e) => { … };
const handleMouseLeave = () => {
setActiveIndex(null);
};
return (
)
Рабочее состояние есть, теперь просто говорим, что нужно рисовать если индекс активный, а что, если нет:
data.map((item, index) => {
return (
…
);
})
И теперь смотрим на результат:
Итог
Мы сделали линейный график с минимальной функциональностью, но он довольно неплохо демонстрирует ключевые моменты, так как в нем есть и сложные фигуры, и кастомные оси, и даже эффекты взаимодействия. Вот собственно наш пример:
D3 — это мощный инструмент, но существенная часть библиотеки устарела, поэтому нужно выбирать те вещи, которые действительно нам облегчат жизнь. Соответственно, мы берем из D3 только функции для масштабирования и методы для создания сложных фигур.
Мы выкидываем из D3 все устаревшие методы для прямой манипуляции элементами DOMа и делам это как знали и умели до этого.
В интернете будет много примеров, которые будут сбивать вас с толку и заставлять писать в стиле jQuery, будьте внимательны. Надеюсь эта статья вам поможет сделать всё красиво!