Как подружить React и D3

в 8:24, , рубрики: D3, javascript, React, ReactJS, визуализация данных, интерактивная визуализация

D3 одна из наиболее популярных javascript-библиотек для создания динамических и интерактивных визуализаций данных. Сегодня ее используют сотни тысяч сайтов и web-приложений.

В интернете огромное количество примеров – от банальных линейных графиков до динамически обновляющихся диаграмм Вороного – созданных с помощью этой библиотеки. Кажется, что можно найти готовый код для любой самой причудливой визуализации и лишь немного модифицировать его «под себя».

Однако, интеграция D3 в web-приложение, построенное на React, на практике оказывается не самой простой задачей.

Как подружить React и D3 - 1

Проблема в том, что D3 и React оба хотят контролировать DOM. Значит ли, что совместное использование этих библиотек невозможно? Разумеется, нет.

В данной статье на примере создания гистограммы я предлагаю рассмотреть 4 различных подхода к решению данной проблемы, а также обсудить их плюсы и минусы.

Ниже представлен код создания гистограммы на чистом D3, который мы хотим превратить в полноценный компонент React.

Код гистограммы
import * as d3 from "d3";

const animDuration = 600;

class BarChartVanilla {

    constructor(selector, size) {
        this.size = size;
        this.conatiner = d3.select(selector)
                    .append("svg")
                        .attr("width", size.width)
                        .attr("height", size.height);

        this.scaleColor = d3.scaleSequential(d3.interpolateViridis);
        this.scaleHeight = d3.scaleLinear().range([0, size.height - 20]);
        this.scaleWidth = d3.scaleBand().range([0, size.width]).padding(0.1);
    }

    draw(data) {
        this.scaleColor.domain([0, data.length]);
        this.scaleWidth.domain(data.map((d) => (d.item)));
        this.scaleHeight.domain(d3.extent(data, (d) => (d.count)));

        const bars = this.conatiner
                            .selectAll(".bar")
                            .data(data, function key(d) { return d.item });

        bars.exit()
            .transition().duration(animDuration)
                .attr("y", this.size.height)
                .attr("height", 0)
                .style("fill-opacity", 0)
            .remove();

        bars.enter()
            .append("rect")
                .attr("class", "bar")
                .attr("y", this.size.height)
                .attr("x", this.size.width )
                .attr("rx", 5 ).attr("ry", 5 )
            .merge(bars)
                .transition().duration(animDuration)
                .attr("y", (d) => ( this.scaleHeight(d.count) ))
                .attr("height", (d) => (this.size.height - this.scaleHeight(d.count)) )
                .attr("x", (d, i) => ( this.scaleWidth(d.item) ) )
                .attr("width", this.scaleWidth.bandwidth() )
                .style("fill",  (d, i) => ( this.scaleColor(i) ));
    }
}

export default BarChartVanilla;

Подход #1. React для структуры, D3 для визуализации

В данном случае React используется только для рендеринга html-контейнера (чаще всего ) визуализации. Все фактические манипуляции с данными и их представлением внутри созданного контейнера остаются за D3.

Код компонента

import React from "react";
import PropTypes from "prop-types"; 
import * as d3 from "d3";

class BarChartV1 extends React.Component {

    scaleColor = d3.scaleSequential(d3.interpolateViridis);
    scaleHeight = d3.scaleLinear();
    scaleWidth = d3.scaleBand().padding(0.1);

    componentDidMount() {
        this.updateChart();
    }

    componentDidUpdate() {
        this.updateChart();
    }

    updateChart() {
        this.updateScales();
        const { data, width, height, animDuration } = this.props;
        const bars = d3.select(this.viz)
                            .selectAll(".bar")
                            .data(data, function key(d) { return d.item });

        bars.exit()
            .transition().duration(animDuration)
                .attr("y", height)
                .attr("height", 0)
                .style("fill-opacity", 0)
            .remove();

        bars.enter()
            .append("rect")
                .attr("class", "bar")
                .attr("y", height)
                .attr("rx", 5 ).attr("ry", 5 )
            .merge(bars)
                .transition().duration(animDuration)
                .attr("y", (d) => ( this.scaleHeight(d.count) ))
                .attr("height", (d) => (height - this.scaleHeight(d.count)) )
                .attr("x", (d) => ( this.scaleWidth(d.item) ) )
                .attr("width", this.scaleWidth.bandwidth() )
                .style("fill",  (d) => ( this.scaleColor(d.item) ));
    }

    updateScales() {
        const { data, width, height } = this.props;
        this.scaleColor.domain([0, data.length]);
        this.scaleWidth
                 .domain(data.map((d) => (d.item)))
                 .range([0, width]);
        this.scaleHeight
                  .domain(d3.extent(data, (d) => (d.count)))
                  .range([height - 20, 0]);
    }

    render() {
        const { width, height } = this.props;
        return (
            <svg ref={ viz => (this.viz = viz) }
                   width={width} height={height} >
            </svg>
        );    
    }
}

BarChartV1.defaultProps = {
    animDuration: 600
};

BarChartV1.propTypes = {
     data: PropTypes.array.isRequired,
      width: PropTypes.number.isRequired,
     height: PropTypes.number.isRequired,
     animDuration: PropTypes.number
};

export default BarChartV1;

Плюсы

  • Позволяет использовать все возможности D3.js в рамках выделенной части DOM.
  • Не требуется дополнительных усилий для интеграции ранее созданных на D3 компонентов.

Минусы

  • He использует возможностей React по оптимизации обновления.
  • Затрудняет тестирование и поддержку кода компонента.

Подход #2. React для манипулирования DOM, D3 для вычислений

D3.js включает в себя несколько десятков различных модулей, лишь немногие из которых непосредственно связаны с DOM. Многочисленные хелперы для предварительной подготовки данных, работы с цветами и географическими объектами, реализованные алгоритмы для расчета кривых, построения графов и интерполяции данных могут заметно облегчить жизнь разработчику, даже в случае когда рендеринг и обновление всех svg-элементов реализуется в рамках жизненного цикла компонента React.

Код компонента

import React from "react";
import PropTypes from "prop-types"; 
import * as d3 from "d3";

class BarChartV2 extends React.Component {

    scaleColor = d3.scaleSequential(d3.interpolateViridis);
    scaleHeight = d3.scaleLinear();
    scaleWidth = d3.scaleBand().padding(0.1);

    render() {
        this.updateScales();
        const { width, height, data } = this.props;
        const bars = data.map((d) => (
                           <rect key={d.item}
                                 width={this.scaleWidth.bandwidth()}
                                 height={height - this.scaleHeight(d.count)}
                                 x={ this.scaleWidth(d.item)}
                                 y={this.scaleHeight(d.count)}
                                 fill={this.scaleColor(d.item)}
                                 rx="5" ry="5"
                            />));
        return (
            <svg width={width} height={height} >
                { bars }
            </svg>
        );    
    }

    updateScales() {
        const { data, width, height } = this.props;
        this.scaleColor.domain([0, data.length]);
        this.scaleWidth
                   .domain(data.map((d) => (d.item)))
                   .range([0, width]);
        this.scaleHeight
                   .domain(d3.extent(data, (d) => (d.count)))
                   .range([height - 20, 0]);
    }
}

BarChartV2.defaultProps = {
    animDuration: 600
};

BarChartV2.propTypes = {
    data: PropTypes.array.isRequired,
    width: PropTypes.number.isRequired,
    height: PropTypes.number.isRequired,
    animDuration: PropTypes.number
};

export default BarChartV2;

Плюсы

  • Оптимизация производительности при работе с DOM, полностью контролируемой React
  • Понятная структура и логика работы компонента даже для разработчиков, не знакомых с D3.js

Минусы

  • Ограниченное использование возможностей D3.js
  • Требуются дополнительные усилия для портирования кода, написанного на чистом D3.js
  • Результирующий код сильно привязан к структуре компонента React и его жизненному циклу. Созданную таким образом визуализацию будет сложно переиспользовать в проектах, созданных с использованием других фреймворков или библиотек (Angualar, VueJS)

Подход #3. React для создания/удаления элементов визуализации, D3 для обновления

В этом случае контроль над всей внутренней структурой узла DOM остается за React. D3.js лишь модифицирует атрибуты, уже созданных элементов.

Код компонента

import React from "react";
import PropTypes from "prop-types"; 

import * as d3 from "d3";

class BarChartV3 extends React.Component {

    scaleColor = d3.scaleSequential(d3.interpolateViridis);
    scaleHeight = d3.scaleLinear();
    scaleWidth = d3.scaleBand().padding(0.1);

    componentDidMount() {
        this.updateChart();
    }

    componentDidUpdate() {
        this.updateChart();
    }

    updateChart() {
        this.updateScales();      
        const { data, height, animDuration } = this.props;
        const bars = d3.select(this.viz)
                            .selectAll(".bar")
                            .data(data, function(d) { return d ? d.item : d3.select(this).attr("item"); });
        bars
            .transition().duration(animDuration)
                .attr("y", (d) => ( this.scaleHeight(d.count) ))
                .attr("height", (d) => (height - this.scaleHeight(d.count)) )
                .attr("x", (d) => ( this.scaleWidth(d.item) ) )
                .attr("width", this.scaleWidth.bandwidth() )
                .style("fill",  (d) => ( this.scaleColor(d.item) ));
    }

    updateScales() {
        const { data, width, height } = this.props;
        this.scaleColor.domain([0, data.length]);
        this.scaleWidth
                 .domain(data.map((d) => (d.item)))
                 .range([0, width]);
        this.scaleHeight
                  .domain(d3.extent(data, (d) => (d.count)))
                  .range([height - 20, 0]);
    }

    render() {
        const { width, height, data } = this.props;
        const bars = data.map((d) => (
                        <rect key={d.item}
                            item={d.item}
                            className="bar"
                            y={height} rx="5" ry="5"
                        />));
        return (
            <svg ref={ viz => (this.viz = viz) }
                        width={width} height={height} >
                { bars }
            </svg>
        );        
    }
}

BarChartV3.defaultProps = {
    animDuration: 600
};

BarChartV3.propTypes = {
     data: PropTypes.array.isRequired,
      width: PropTypes.number.isRequired,
     height: PropTypes.number.isRequired,
     animDuration: PropTypes.number
};

export default BarChartV3;

Плюсы

  • Более широкое использование возможностей D3, включая анимированные переходы между различными состояниями объектов
  • Улучшенная производительность за счет использования React для манипулирования структурой визуализации (добавлением/удалением ее элементов)

Минусы

  • Нельзя использовать d3 транзакции для удаляемых элементов
  • Данное решение также достаточно сильно привязано к структуре React компонента (пусть и в меньшей степени, чем вариант 2)

Подход #4. Использовать фейковый DOM для D3

Данный подход предполагает создание объекта, похожего на DOM, который будет использован для работы с D3. Это позволит с одной стороны использовать все API D3.js, а с другой – отдать React полный контроль над реальным DOM.

Оливер Колдвел (Oliver Caldwell), предложивший данную идею, создал react-faux-dom, который автоматически сохраняет созданные/измененные D3 элементы в state компонента. А затем уже React определяет нужно ли обновлять и в каком объеме настоящий DOM.

Код компонента

import React from "react";
import PropTypes from "prop-types"; 
import * as d3 from "d3";
import { withFauxDOM } from 'react-faux-dom'

class BarChartV4 extends React.Component {

    scaleColor = d3.scaleSequential(d3.interpolateViridis);
    scaleHeight = d3.scaleLinear();
    scaleWidth = d3.scaleBand().padding(0.1);

    componentDidMount() {
        this.updateChart();
    }

    componentDidUpdate (prevProps, prevState) { 
        if (this.props.data !== prevProps.data) {
            this.updateChart();
        }
    }

    updateChart() {
    this.updateScales();

    const { data, width, height, animDuration } = this.props;
        const faux = this.props.connectFauxDOM("g", "chart");
        const bars = d3.select(faux)
                            .selectAll(".bar")
                            .data(data, function key(d) { return d.item });
        bars.exit()
            .transition().duration(animDuration)
                .attr("y", height)
                .attr("height", 0)
                .style("fill-opacity", 0)
            .remove();

        bars.enter()
            .append("rect")
                .attr("class", "bar")
                .attr("y", height)
                .attr("x", width )
                .attr("width", 0)
                .attr("height", 0)
                .attr("rx", 5 ).attr("ry", 5 )
            .merge(bars)
                .transition().duration(animDuration)
                .attr("y", (d) => ( this.scaleHeight(d.count) ))
                .attr("height", (d) => (height - this.scaleHeight(d.count)) )
                .attr("x", (d, i) => ( this.scaleWidth(d.item) ) )
                .attr("width", this.scaleWidth.bandwidth() )
                .style("fill",  (d, i) => ( this.scaleColor(i) ));

        this.props.animateFauxDOM(800);
    }

    updateScales() {
        const { data, width, height } = this.props;
        this.scaleColor.domain([0, data.length]);
        this.scaleWidth
                 .domain(data.map((d) => (d.item)))
                 .range([0, width]);
        this.scaleHeight
                 .domain(d3.extent(data, (d) => (d.count)))
                 .range([height - 20, 0]);
    }

    render() {
        const { width, height } = this.props;
        return (
            <svg width={width} height={height} >
                { this.props.chart }
            </svg>
        );    
    }
}

BarChartV4.defaultProps = {
    animDuration: 600
};

BarChartV4.propTypes = {
     data: PropTypes.array.isRequired,
      width: PropTypes.number.isRequired,
     height: PropTypes.number.isRequired,
     animDuration: PropTypes.number
};

export default withFauxDOM(BarChartV4);

Плюсы

  • D3-составляющая компонента будет аналогична версии, написанной на чистом D3. A значит, можно легко интегрировать код из обширной базы примеров и нет привязки к конкретному фреймворку.
  • Поддерживается все API D3, включая анимацию и обработку событий.
  • Задействуются все стандартные методы и встроенные оптимизации React для ускорения отрисовки компонента.

Минусы

  • Необходимость использования еще одной сторонней библиотеки или написания своей имплементации fake DOM объекта.

Заключение

Разумеется, если ваше приложение не выходит за рамки построения стандартных гистограмм, линейных графиков или круговых диаграмм, то лучше воспользоваться одной из множества библиотек для построения графиков, изначально созданных под React.

Однако, D3 может оказаться незаменимым помощником при создании сложных дашбордов, инфографик и интерактивных историй. Поэтому стоит внимательно взвесить все «за» и «против» при выборе того или иного подхода. Замечу, что реальных проектах чаще всего используются два последних подхода.

Стоит также обратить внимание на такие библиотеки как Semiotic.js и Recharts, построенные на основе D3 и React.

Автор: maryzam

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js