Библиотека D3.js: инструмент для современной веб-визуализации

Раздел: Веб-разработка -> Визуализация данных

Основные возможности D3.js

Библиотека D3.js (Data-Driven Documents) позволяет связывать данные с элементами DOM и создавать динамические, интерактивные визуализации. В основе лежат выборки, привязка данных, шкалы и оси.

Наиболее эффективный способ начать работу с D3.js - создать простую гистограмму. Ниже приведён полный пример с пояснениями.


<!DOCTYPE html>
<html><head>
  <script src="https://d3js.org/d3.v7.min.js"></script>
</head><body>
  <svg width="600" height="400"></svg>
  <script>
    const data = [10, 40, 25, 60, 35];
    const svg = d3.select('svg');
    const width = +svg.attr('width');
    const height = +svg.attr('height');
    const margin = {top:20, right:20, bottom:30, left:40};
    const innerWidth = width - margin.left - margin.right;
    const innerHeight = height - margin.top - margin.bottom;

    const xScale = d3.scaleBand()
      .domain(data.map((_,i) => i))
      .range([0, innerWidth])
      .padding(0.1);
    const yScale = d3.scaleLinear()
      .domain([0, d3.max(data)])
      .range([innerHeight, 0]);

    const g = svg.append('g')
      .attr('transform', `translate(${margin.left},${margin.top})`);

    g.selectAll('rect')
      .data(data)
      .enter()
      .append('rect')
        .attr('x', (_,i) => xScale(i))
        .attr('y', d => yScale(d))
        .attr('width', xScale.bandwidth())
        .attr('height', d => innerHeight - yScale(d))
        .attr('fill', 'steelblue');

    g.append('g')
      .call(d3.axisLeft(yScale));
    g.append('g')
      .attr('transform', `translate(0,${innerHeight})`)
      .call(d3.axisBottom(xScale)
        .tickFormat(i => `Кат ${i+1}`));
  </script>
</body></html>

D js (d3.js)

Пояснение: Выборка d3.select находит SVG-элемент. Шкалы преобразуют данные в координаты. Привязка данных через .data().enter() создаёт прямоугольники для каждого элемента массива. Оси добавляются с помощью d3.axisLeft и d3.axisBottom.

Типичная ошибка: Забывают вызвать .enter() после .data(), из-за чего новые элементы не создаются. Решение: всегда использовать цепочку .data().enter().append() для новых данных.

Ещё одна проблема: оси перекрываются, если не заданы внутренние отступы (margin). Всегда вычитайте margin из размеров SVG.

Как создать круговую диаграмму с подписями?

Для круговой диаграммы используется генератор дуг d3.arc и макет d3.pie.


  const data = [30, 50, 20];
  const colors = ['#ff6b6b','#48dbfb','#ff9ff3'];
  const svg = d3.select('svg');
  const radius = 150;
  const g = svg.append('g')
    .attr('transform', 'translate(200,200)');

  const pie = d3.pie().value(d => d);
  const arc = d3.arc().innerRadius(0).outerRadius(radius);
  const labelArc = d3.arc().innerRadius(radius*0.6).outerRadius(radius);

  const arcs = g.selectAll('path')
    .data(pie(data))
    .enter()
    .append('path')
      .attr('d', arc)
      .attr('fill', (_,i) => colors[i]);

  g.selectAll('text')
    .data(pie(data))
    .enter()
    .append('text')
      .attr('transform', d => `translate(${labelArc.centroid(d)})`)
      .attr('text-anchor', 'middle')
      .text(d => `${d.data}%`);

Пояснение: d3.pie() вычисляет начальные и конечные углы. d3.arc строит пути. Для подписей используется labelArc.centroid для позиционирования текста.

Проблема: Маленькие сегменты могут накладываться подписями. Решение: использовать d3.arc().innerRadius() с большим значением, чтобы текст располагался дальше от центра, или добавить выноски.

Как добавить анимацию при появлении данных?

D3 имеет встроенную поддержку переходов (.transition()). Анимация гистограммы:


  // после .append('rect')
  .attr('y', innerHeight) // начальное положение внизу
  .attr('height', 0)
  .transition()
    .duration(800)
    .delay((_,i) => i * 100)
    .attr('y', d => yScale(d))
    .attr('height', d => innerHeight - yScale(d));

Для круговой диаграммы можно анимировать дуги, изменяя endAngle с 0 до конечного угла.

Ошибка: Применение .transition() до привязки данных приводит к анимации неправильных элементов. Всегда сначала создавайте элементы, затем вызывайте переход.

Как реализовать интерактивность с помощью событий мыши?

Пример добавления тултипа при наведении на столбец гистограммы:


  // предварительно создан div.tooltip в HTML
  .on('mouseover', function(event, d) {
    d3.select(this).attr('fill', 'orange');
    d3.select('.tooltip')
      .style('opacity', 1)
      .html(`Значение: ${d}`)
      .style('left', (event.pageX+10) + 'px')
      .style('top', (event.pageY-20) + 'px');
  })
  .on('mouseout', function() {
    d3.select(this).attr('fill', 'steelblue');
    d3.select('.tooltip').style('opacity', 0);
  });

Пояснение: События mouseover/mouseout изменяют цвет и показывают/скрывают подсказку. Позиция вычисляется из event.pageX/pageY.

Проблема: Тултип может выходить за границы экрана. Решение: проверять event.pageX + offset > window.innerWidth и корректировать.

Расширенные примеры с D3.js

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

Интерактивный хороплет (карта мира с данными)

Хороплет - это карта, где регионы окрашены в зависимости от значения. Для этого нужны геоданные (TopoJSON/GeoJSON). Пример с использованием данных по населению стран (упрощённо):

Пример

<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://d3js.org/topojson.v3.min.js"></script>
<script>
const width = 960, height = 600;
const svg = d3.select('body').append('svg')
  .attr('width', width).attr('height', height);

const projection = d3.geoMercator()
  .scale(150)
  .translate([width/2, height/1.5]);

const path = d3.geoPath().projection(projection);

// данные стран (пример с фиктивными значениями)
const dataByCountry = { 'USA': 330, 'China': 1400, 'India': 1300, 'Brazil': 210 };

// загрузка топологии мира
d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json')
  .then(world => {
    const countries = topojson.feature(world, world.objects.countries);
    svg.selectAll('path')
      .data(countries.features)
      .enter()
      .append('path')
        .attr('d', path)
        .attr('fill', d => {
          const val = dataByCountry[d.properties.name];
          if (!val) return '#ccc';
          // цветовая шкала
          return d3.scaleSequential(d3.interpolateBlues).domain([0,1500])(val);
        })
        .append('title')
          .text(d => `${d.properties.name}: ${dataByCountry[d.properties.name] || 'нет данных'} млн`);
  });
</script>
Результат: карта мира, где страны окрашены от светло- до тёмно-синего в зависимости от населения. При наведении появляется подсказка с названием и значением.

Пояснение: Используется проекция geoMercator, загружаются границы стран из TopoJSON. Значения привязываются по имени страны. Цвет задаётся через scaleSequential.

Force-directed graph (граф социальных связей)

Силовая диаграмма располагает узлы и рёбра с помощью физического моделирования.

Пример

const nodes = [
  {id: 'Alice'}, {id: 'Bob'}, {id: 'Charlie'},
  {id: 'Diana'}, {id: 'Eve'}
];
const links = [
  {source: 'Alice', target: 'Bob'},
  {source: 'Bob', target: 'Charlie'},
  {source: 'Charlie', target: 'Diana'},
  {source: 'Diana', target: 'Eve'},
  {source: 'Eve', target: 'Alice'},
  {source: 'Alice', target: 'Charlie'}
];

const svg = d3.select('svg');
const width = +svg.attr('width');
const height = +svg.attr('height');

const simulation = d3.forceSimulation(nodes)
  .force('link', d3.forceLink(links).id(d => d.id))
  .force('charge', d3.forceManyBody().strength(-100))
  .force('center', d3.forceCenter(width/2, height/2));

const link = svg.append('g')
  .selectAll('line')
  .data(links)
  .enter()
  .append('line')
    .attr('stroke', '#999')
    .attr('stroke-width', 2);

const node = svg.append('g')
  .selectAll('circle')
  .data(nodes)
  .enter()
  .append('circle')
    .attr('r', 10)
    .attr('fill', '#69b3a2')
    .call(d3.drag()
      .on('start', (event, d) => {
        if (!event.active) simulation.alphaTarget(0.3).restart();
        d.fx = d.x;
        d.fy = d.y;
      })
      .on('drag', (event, d) => {
        d.fx = event.x;
        d.fy = event.y;
      })
      .on('end', (event, d) => {
        if (!event.active) simulation.alphaTarget(0);
        d.fx = null;
        d.fy = null;
      })
    );

simulation.on('tick', () => {
  link
    .attr('x1', d => d.source.x)
    .attr('y1', d => d.source.y)
    .attr('x2', d => d.target.x)
    .attr('y2', d => d.target.y);
  node
    .attr('cx', d => d.x)
    .attr('cy', d => d.y);
});
Результат: узлы (круги) соединены линиями, они двигаются и располагаются так, чтобы минимизировать пересечения. Узлы можно перетаскивать.

Пояснение: d3.forceSimulation управляет физикой. forceLink создаёт рёбра, forceManyBody - отталкивание. Обработчик tick обновляет позиции.

Иерархическое дерево с анимацией сворачивания/разворачивания

Древовидная диаграмма с возможностью раскрытия дочерних узлов. Используется d3.tree и переходы.

Пример

const data = {
  name: 'Root',
  children: [
    { name: 'Child1', children: [{ name: 'Grandchild1' }, { name: 'Grandchild2' }] },
    { name: 'Child2', children: [{ name: 'Grandchild3' }] }
  ]
};

const width = 600, height = 400;
const svg = d3.select('svg')
  .attr('width', width)
  .attr('height', height);
const g = svg.append('g').attr('transform', 'translate(50,20)');

const root = d3.hierarchy(data);
const treeLayout = d3.tree().size([width-100, height-40]);
root.x0 = 0;
root.y0 = 0;

function update(source) {
  const treeData = treeLayout(root);
  const nodes = treeData.descendants();
  const links = treeData.links();

  // Обновление узлов (круги + текст)
  const node = g.selectAll('g.node')
    .data(nodes, d => d.data.name);

  const nodeEnter = node.enter()
    .append('g')
      .attr('class', 'node')
      .attr('transform', d => `translate(${source.x0},${source.y0})`)
    .on('click', (event, d) => {
      if (d.children) {
        d._children = d.children;
        d.children = null;
      } else if (d._children) {
        d.children = d._children;
        d._children = null;
      }
      update(d);
    });

  nodeEnter.append('circle')
    .attr('r', 1e-6)
    .attr('fill', d => d.children ? 'lightsteelblue' : 'white')
    .attr('stroke', 'steelblue');

  nodeEnter.append('text')
    .attr('dy', '.35em')
    .attr('x', d => d.children ? -12 : 12)
    .attr('text-anchor', d => d.children ? 'end' : 'start')
    .text(d => d.data.name);

  // переход узлов в новые позиции
  const nodeUpdate = nodeEnter.merge(node);
  nodeUpdate.transition()
    .duration(500)
    .attr('transform', d => `translate(${d.x},${d.y})`);

  nodeUpdate.select('circle')
    .attr('r', 6);

  const nodeExit = node.exit().transition()
    .duration(500)
    .attr('transform', d => `translate(${source.x},${source.y})`)
    .remove();
  nodeExit.select('circle').attr('r', 1e-6);
  nodeExit.select('text').style('fill-opacity', 1e-6);

  // линии (ребра)
  const link = g.selectAll('path.link')
    .data(links, d => d.target.data.name);

  const linkEnter = link.enter()
    .append('path')
      .attr('class', 'link')
      .attr('d', d3.linkHorizontal()
        .x(d => source.x0)
        .y(d => source.y0));

  linkEnter.merge(link).transition()
    .duration(500)
    .attr('d', d3.linkHorizontal()
      .x(d => d.x)
      .y(d => d.y));

  link.exit().transition()
    .duration(500)
    .attr('d', d3.linkHorizontal()
      .x(d => source.x)
      .y(d => source.y))
    .remove();

  // сохраняем текущие позиции для следующей анимации
  nodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; });
}

update(root);
Результат: иерархическое дерево с узлами, которые можно кликать для раскрытия/сворачивания дочерних элементов. Анимация плавно переводит узлы и рёбра.

Пояснение: Используется d3.hierarchy для преобразования данных, d3.tree для вычисления координат. Событие клика переключает children/_children. Переходы (.transition()) анимируют изменения.

D3.js - comments

En
D js (javascript)