Визуализация сложных данных с использованием D3 и React

  • Tutorial

Существует много возможны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 (
  <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.


Вот как выглядят наши подписи к точкам:




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 (
  <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;
  }
}

Посмотрим как сейчас выглядит наш пример:



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 (
  <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>
);

Смотрим на нашу красоту:





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
    …
    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, будьте внимательны. Надеюсь эта статья вам поможет сделать всё красиво!

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 13

    0
    … или взять dc.jscrossfilter)
      0
      Спасибо!
        0

        Статья супер!
        А будет продолжение?
        Как делать легенду к графику? Как делать перетаскивание, зум, autoresize, выделение области, зум области, всплывающее окно при наведении?

          0

          Спасибо за отзыв! Насчет продолжения еще не успел подумать, все возможно :)

          0
          Я тоже много библиотек для графиков перепробовал и остановился на plotly.js.
          Меня привлек готовый функционал навигации по графикам (перетаскивание, масштабирование). Но как сделать там свой курсор ума не приложу.
          Что можете сказать про plotly.js по сравнению с recharts.js?
            0

            Я plotly.js не пробовал если честно. Но вы также зашли в тупик, так почти всегда с готовыми решениями, когда нужен сильный кастом

              0
              Но ведь стоимость поддержки своего кода тоже имеет значение. Со своим кодом вы точно также можете однажды зайти в тупик, когда новая фича может оказаться очень дорогой.

              Сейчас как раз перехожу на plotly.js с кастомного решения. Решение было хорошим (не моим), но… начисто проигрывало plotly.js по функционалу и по производительности.
              0
              Но как сделать там свой курсор ума не приложу.
              А что вы имеете ввиду под своим курсором?
              Что можете сказать про plotly.js по сравнению с recharts.js?
              Сравнивал недавно, Recharts просто-напросто очень медленный. В принципе это было ожидаемо (реакт жеж).
                0
                В моем случае курсор — это вертикальная линия (срез) по всем графикам. Этот курсор привязан к данным по оси Х и следит за перемещением мыши.
                  0
                  Если вы используете обёртку react-plotly, то передавайте коллбек в проп `onHover`, иначе подписывайтесь на событие `plotly_hover`. В коллбек приходит аргумент с типом `PlotMouseEvent`, в нём есть всё необходимое для этого. Я скрываю стандартные тултипы, оставляю только вертикальную линию, и с помощью данных из этого plotMouseEvent рисую всё, что мне надо.

                  Это описано в документации (которой много, да), и можно найти кучу примеров на community.plotly.com. Вообще пока с проблемами кастомизации в плотли сталкивался только в funnel chart — трапеции нужны вместо прямоугольников, но и это решается через изменение svg в коллбеке события relayout.
                    0
                    Все получилось, спасибо!
              +1
              При этом, скажу сразу, что большая часть этой библиотеки уже устарела, и ее не стоит использовать. Именно ту часть, где идут манипуляции с DOM узлами, эту задачу мы будем максимально перекладывать на React.

              Очень сильно утверждение.
              Мне кажется вы до конца не разобрались в чем именно сила d3. Она не в наборе библиотечных функций для построения layout и т.п., а как раз в работе с DOM и динамическом обновлении данных. Данные — в данном случае это не просто точки, а некоторые сущности (следовало бы добавить для них id). И d3 через паттерн enter update exit позволяет оперировать разными состояниями этих сущностей. Можно отдельно анимировать появление новых сущностей, обновлять уже существующих и удалять устаревшие.

              Попробуйте, например в ваш пример scatter plot (№3) добавить fadeIn, fadeOut для появления и удаления сущностей, а также анимирование перемещения существующих окружностей и вы поймете, что в реакте с этим туго.

              Вообще как мне кажеться, идея «Виртуального дома» была успешно заимтвована из d3 selection
                0

                Спасибо за замечание! Возможно я не очень корректно выразился в этом плане.


                Посыл был в том, чтобы как можно больше задействовать инструменты, которые мы уже знаем – все анимации и обновление DOM через React. Это критически важно, когда вы делаете проект, который будут поддерживать после вас, чтобы человек смог быстро разобраться, не погружаясь в глубины D3

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое