Визуализация сложных данных с использованием D3 и React
- пятница, 16 октября 2020 г. в 00:27:20
Существует много возможныx вариантов реализации сложных графиков в ваших проектах. Я за несколько лет попробовал все возможные варианты. Сначала это были готовые библиотеки типа AmCharts 4. AmCharts сразу же оказался большим и неповоротливым. После этого были более гибкие и дружелюбные библиотеки, такие как Recharts. Recharts был поначалу очень хорош, но со временем сложные фичи создавались такими костылями, которые даже показывать стыдно, а какие-то фичи и вовсе были невозможны в реализации. Таким образом, я пришел к D3 и решаю на нем любые задачи, связанные с графиками. Иногда это занимает немного больше времени по сравнению с готовыми инструментами. Но остается одно неоспоримое преимущество – мы всегда знаем, что никогда не упремся в рамки и ваш код не захочется отправить в помойку через пару месяцев.
Какая цель этой статьи? Я хочу рассказать вам про крутой инструмент и о том, как его максимально эффективно использовать в связке с React. Мы последовательно разберем универсальный рецепт для построения компонентов любой сложности.
Под сложными данными в контексте этой статьи я подразумеваю данные, которые тяжело воспринимать каким-то простым способом (текстом, списком или таблицей). Поэтому для визуального восприятия различной информации мы используем определённые типы графиков.
Пару примеров эффективного восприятия информации:
D3.js – это javaScript библиотека для обработки и визуализации данных. Она включает в себя функции для масштабирования, утилиты для манипуляции с данными и DOM-узлами.
При этом, скажу сразу, что большая часть этой библиотеки уже устарела, и ее не стоит использовать. Именно ту часть, где идут манипуляции с DOM узлами, эту задачу мы будем максимально перекладывать на React.
Я не просто так начинаю с этого пункта. Самое первое, что необходимо сделать, когда приступаем к разработке графика – это абстрагироваться от физических размеров. Ну скажем, чтобы мы могли получать координаты точек и сразу же записывать их в атрибуты. Соответственно, нам нужны какие-то методы, которые получают на вход значение или название категории, а на выходе отдают координаты в пикселях.
getY(`значение`); \\ возвращает координату по оси y в пикселях
getX(`название категории`); \\ возвращает координату по оси x в пикселях
Мы один раз создаем такие функции на один компонент, какой бы он сложный не оказался в итоге. А дальше используем эти функции везде, где нужно создать какой-то элемент, позиция которого зависит от данных.
К счастью в D3 это сделать очень просто.
На изображении показано положение точек из массива [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
. Мы хотим один раз подвязаться к категориям, а дальше передавать название категории и получать ее координаты.
На изображении мы видим контейнер шириной 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
(отступ слева).
С использованием наших функций для получения координат мы уже можем рисовать простые фигуры на координатной плоскости. К простым фигурам в текущем контексте я отношу:
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 (
<svg width={600} height={300}>
{data.map((item, index) => {
return (
<circle
key={index}
cx={getX(item.name) + getX.bandwidth() / 2}
cy={getY(item.value)}
r={4}
fill="#7cb5ec"
/>
);
})}
</svg>
);
Фигура circle абсолютно примитивна. В данном случае она принимает координаты центра – cx
, cy
, радиус r
и цвет заливки fill
.
Здесь мы использовали новый метод bandwidth
:
getX.bandwidth()
Данный метод возвращает ширину колонки – расстояние от одного месяца до соседнего. Мы применяем этот метод для того, чтобы сдвинуть наши точки до центра колонки:
getX(item.name) + getX.bandwidth() / 2
Вот, что у нас получится в результате:
Для создания текстовых узлов в svg используется фигура text
. Она также принимает координаты и содержит свои личные атрибуты для стилизации.
Подпишем значения на наших точках:
return (
<svg ...>
{data.map((item, index) => {
return (
<g key={index}>
<circle ... />
<text
fill="#666"
x={getX(item.name) + getX.bandwidth() / 2}
y={getY(item.value) - 10}
textAnchor="middle"
>
{item.value}
</text>
</g>
);
})}
</svg>
);
Что здесь нового? Мы обернули наш круг и текст элементом g
. Элемент g
один из самых распространенных в svg, обычно он просто группирует элементы и двигает их вместе при необходимости через свойство transform
.
Вот как выглядят наши подписи к точкам:
Для осей существуют готовые элементы в 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 (
<svg ...>
<g ref={getYAxis} />
<g
ref={getXAxis}
transform={`translate(0,${getY(0)})`} // нужно сдвинуть ось в самый низ svg
/>
...
</svg>
);
Вот что получается, если ничего не менять и не настраивать:
Попробуем добавить немного красоты и переопределим изначальные стили:
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 (
<svg ...>
<g className="axis"
ref={getYAxis}
/>
<g
className="axis xAxis"
ref={getXAxis}
transform={`translate(0,${getY(0)})`}
/>
...
</svg>
);
И немного стилей:
.axis {
color: #ccd6eb;
& text {
color: #666;
}
& .domain {
display: none;
}
}
.xAxis {
& line {
display: none;
}
}
Посмотрим как сейчас выглядит наш пример:
У 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 (
<svg ...>
…
<path
strokeWidth={3}
fill="none"
stroke="#7cb5ec"
d={linePath}
/>
…
</svg>
);
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 (
<svg ...>
…
<path
fill="#7cb5ec"
d={areaPath}
opacity={0.2}
/>
…
</svg>
);
Смотрим на нашу красоту:
Мы игнорируем все методы для навешивания событий из 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
…
onMouseMove={handleMouseMove}
>
…
</svg>
)
И добавим событие, которое будет сбрасывать активный индекс, когда мы убираем мышку с svg:
const handleMouseMove = (e) => { … };
const handleMouseLeave = () => {
setActiveIndex(null);
};
return (
<svg
…
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
…
</svg>
)
Рабочее состояние есть, теперь просто говорим, что нужно рисовать если индекс активный, а что, если нет:
data.map((item, index) => {
return (
<g key={index}>
<circle
cx={getX(item.name) + getX.bandwidth() / 2}
cy={getY(item.value)}
r={index === activeIndex ? 6 : 4} // при наведении просто немного увеличиваем круг
fill="#7cb5ec"
strokeWidth={index === activeIndex ? 2 : 0} // обводка появляется только при наведении
stroke="#fff" // добавили белый цвет для обводки
style={{ transition: `ease-out .1s` }}
/>
…
</g>
);
})
И теперь смотрим на результат:
Мы сделали линейный график с минимальной функциональностью, но он довольно неплохо демонстрирует ключевые моменты, так как в нем есть и сложные фигуры, и кастомные оси, и даже эффекты взаимодействия. Вот собственно наш пример:
D3 – это мощный инструмент, но существенная часть библиотеки устарела, поэтому нужно выбирать те вещи, которые действительно нам облегчат жизнь. Соответственно, мы берем из D3 только функции для масштабирования и методы для создания сложных фигур.
Мы выкидываем из D3 все устаревшие методы для прямой манипуляции элементами DOMа и делам это как знали и умели до этого.
В интернете будет много примеров, которые будут сбивать вас с толку и заставлять писать в стиле jQuery, будьте внимательны. Надеюсь эта статья вам поможет сделать всё красиво!