Библиотека 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()) анимируют изменения.