Разработка интерактивных дашбордов на D3.js
D3.js — это не чарт-библиотека. Это инструмент для привязки данных к DOM и управления SVG, Canvas и HTML. Большинство готовых библиотек (Chart.js, Recharts, Highcharts) построены поверх D3 или используют похожие идеи, но предлагают фиксированные типы графиков. D3 — уровень ниже: когда нужно визуализировать что-то нестандартное или получить полный контроль над интерактивностью.
Когда D3, а когда готовая библиотека
Recharts или Chart.js — правильный выбор для стандартных задач: линейный тренд, bar chart, pie. Установка, три пропса, готово.
D3 оправдан когда:
- Нужна кастомная проекция (карты, радарные диаграммы с нелинейными осями)
- Интерактивность требует точного управления состоянием (brush selection, zoom с синхронизацией)
- Анимации должны быть привязаны к данным через transitions
- Несколько связанных визуализаций с общим состоянием (brushing & linking)
Структура проекта
src/
├── visualizations/
│ ├── core/
│ │ ├── scales.ts # переиспользуемые d3 scales
│ │ ├── axes.ts # оси с форматированием
│ │ └── tooltip.ts # единый tooltip-менеджер
│ ├── charts/
│ │ ├── LineChart.tsx
│ │ ├── BarChart.tsx
│ │ └── ScatterPlot.tsx
│ └── dashboard/
│ ├── Dashboard.tsx
│ └── useDashboardState.ts
Базовый паттерн: D3 внутри React
Два подхода. Первый — D3 полностью управляет DOM, React рендерит только контейнер:
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
interface LineChartProps {
data: { date: Date; value: number }[];
width?: number;
height?: number;
}
export function LineChart({ data, width = 800, height = 400 }: LineChartProps) {
const svgRef = useRef<SVGSVGElement>(null);
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
useEffect(() => {
if (!svgRef.current || !data.length) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove(); // очищаем перед перерисовкой
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Scales
const xScale = d3
.scaleTime()
.domain(d3.extent(data, d => d.date) as [Date, Date])
.range([0, innerWidth]);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.value) ?? 0])
.nice()
.range([innerHeight, 0]);
// Line generator
const line = d3
.line<{ date: Date; value: number }>()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(d3.curveMonotoneX);
// Axes
g.append('g')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale).ticks(6).tickFormat(d3.timeFormat('%d %b')));
g.append('g').call(
d3.axisLeft(yScale).ticks(5).tickFormat(d => d3.format(',.0f')(+d))
);
// Path
g.append('path')
.datum(data)
.attr('fill', 'none')
.attr('stroke', '#3b82f6')
.attr('stroke-width', 2)
.attr('d', line);
// Dots с tooltip
const tooltip = d3.select('#tooltip');
g.selectAll('.dot')
.data(data)
.join('circle')
.attr('class', 'dot')
.attr('cx', d => xScale(d.date))
.attr('cy', d => yScale(d.value))
.attr('r', 4)
.attr('fill', '#3b82f6')
.on('mouseover', (event, d) => {
tooltip
.style('display', 'block')
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`)
.html(`<strong>${d3.timeFormat('%d %b %Y')(d.date)}</strong><br/>${d3.format(',.0f')(d.value)}`);
})
.on('mouseout', () => {
tooltip.style('display', 'none');
});
}, [data, width, height]);
return (
<>
<svg ref={svgRef} width={width} height={height} />
<div
id="tooltip"
style={{
position: 'absolute',
display: 'none',
background: 'rgba(0,0,0,0.75)',
color: '#fff',
padding: '6px 10px',
borderRadius: 4,
fontSize: 12,
pointerEvents: 'none',
}}
/>
</>
);
}
Второй подход — React рендерит SVG-элементы, D3 используется только для вычислений:
// Только d3-scale, d3-array, d3-shape — без манипуляций с DOM
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, curveMonotoneX } from 'd3-shape';
import { extent, max } from 'd3-array';
export function LineChartReact({ data, width = 800, height = 400 }) {
const margin = { top: 20, right: 30, bottom: 40, left: 50 };
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const xScale = scaleTime()
.domain(extent(data, d => d.date) as [Date, Date])
.range([0, iw]);
const yScale = scaleLinear()
.domain([0, max(data, d => d.value) ?? 0])
.nice()
.range([ih, 0]);
const linePath = line<typeof data[0]>()
.x(d => xScale(d.date))
.y(d => yScale(d.value))
.curve(curveMonotoneX)(data);
return (
<svg width={width} height={height}>
<g transform={`translate(${margin.left},${margin.top})`}>
<path d={linePath ?? ''} fill="none" stroke="#3b82f6" strokeWidth={2} />
{data.map((d, i) => (
<circle key={i} cx={xScale(d.date)} cy={yScale(d.value)} r={4} fill="#3b82f6" />
))}
</g>
</svg>
);
}
Второй подход лучше интегрируется с React DevTools, проще тестировать, но первый даёт больше контроля над transitions и сложными взаимодействиями.
Zoom и Brush
Два самых востребованных взаимодействия в аналитических дашбордах.
// Zoom с синхронизацией по X-оси
function addZoom(svg: d3.Selection<SVGSVGElement, unknown, null, undefined>, xScale: d3.ScaleTime<number, number>, onZoom: (newScale: d3.ScaleTime<number, number>) => void) {
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([1, 20])
.translateExtent([[0, 0], [innerWidth, innerHeight]])
.extent([[0, 0], [innerWidth, innerHeight]])
.on('zoom', (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
const newXScale = event.transform.rescaleX(xScale);
onZoom(newXScale);
});
svg.call(zoom);
}
// Brush для выделения диапазона
function addBrush(g: d3.Selection<SVGGElement, unknown, null, undefined>, onBrush: (range: [Date, Date] | null) => void) {
const brush = d3.brushX()
.extent([[0, 0], [innerWidth, innerHeight]])
.on('end', (event) => {
if (!event.selection) {
onBrush(null);
return;
}
const [x0, x1] = event.selection as [number, number];
onBrush([xScale.invert(x0), xScale.invert(x1)]);
});
g.append('g').attr('class', 'brush').call(brush);
}
Производительность при больших данных
D3 + SVG начинает тормозить после ~5000 точек. Решения:
Canvas вместо SVG для scatter plot с тысячами точек:
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, width, height);
data.forEach(d => {
ctx.beginPath();
ctx.arc(xScale(d.x), yScale(d.y), 3, 0, 2 * Math.PI);
ctx.fillStyle = colorScale(d.category);
ctx.fill();
});
}, [data]);
Decimation — прореживание данных перед рендером. Chart.js имеет встроенный плагин, для D3 реализуется вручную через LTTB (Largest Triangle Three Buckets):
// Упрощённый LTTB
function lttbDecimate(data: Point[], threshold: number): Point[] {
if (data.length <= threshold) return data;
const sampled: Point[] = [data[0]];
const bucketSize = (data.length - 2) / (threshold - 2);
for (let i = 0; i < threshold - 2; i++) {
const rangeStart = Math.floor((i + 1) * bucketSize) + 1;
const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, data.length);
const avgX = data.slice(rangeEnd, rangeEnd).reduce((s, d) => s + d.x, 0);
const avgY = data.slice(rangeEnd, rangeEnd).reduce((s, d) => s + d.y, 0);
let maxArea = -1;
let maxPoint = data[rangeStart];
for (let j = rangeStart; j < rangeEnd; j++) {
const area = Math.abs(
(sampled[sampled.length - 1].x - avgX) * (data[j].y - sampled[sampled.length - 1].y) -
(sampled[sampled.length - 1].x - data[j].x) * (avgY - sampled[sampled.length - 1].y)
);
if (area > maxArea) { maxArea = area; maxPoint = data[j]; }
}
sampled.push(maxPoint);
}
sampled.push(data[data.length - 1]);
return sampled;
}
Синхронизация нескольких графиков
Классический паттерн для аналитических дашбордов — brushing & linking: выделение диапазона на одном графике фильтрует данные на всех остальных.
function Dashboard() {
const [brushRange, setBrushRange] = useState<[Date, Date] | null>(null);
const filteredData = useMemo(() => {
if (!brushRange) return fullData;
return fullData.filter(d => d.date >= brushRange[0] && d.date <= brushRange[1]);
}, [brushRange]);
return (
<div className="grid grid-cols-2 gap-4">
<TimelineChart data={fullData} onBrush={setBrushRange} />
<BarChart data={filteredData} />
<ScatterPlot data={filteredData} />
<MetricsTable data={filteredData} />
</div>
);
}
Экспорт SVG в PNG/PDF
async function exportChart(svgElement: SVGSVGElement, filename: string) {
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svgElement);
const blob = new Blob([svgStr], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = svgElement.viewBox.baseVal.width * 2; // retina
canvas.height = svgElement.viewBox.baseVal.height * 2;
const ctx = canvas.getContext('2d')!;
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
canvas.toBlob(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob!);
a.download = `${filename}.png`;
a.click();
});
};
img.src = url;
}
Адаптивность
Дашборд должен работать на разных экранах. Паттерн с ResizeObserver:
function useChartDimensions(containerRef: RefObject<HTMLDivElement>) {
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
useEffect(() => {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setDimensions({ width, height });
}
});
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
return dimensions;
}
function ResponsiveLineChart({ data }) {
const containerRef = useRef<HTMLDivElement>(null);
const { width, height } = useChartDimensions(containerRef);
return (
<div ref={containerRef} style={{ width: '100%', height: 300 }}>
{width > 0 && <LineChart data={data} width={width} height={height} />}
</div>
);
}
Сроки
Одиночный кастомный график с интерактивностью (tooltip, zoom) — 2–4 дня. Полноценный аналитический дашборд с 4–6 взаимосвязанными визуализациями, фильтрами, синхронизацией и экспортом — 3–5 недель. Сложные карты (геовизуализация, проекции) — отдельная оценка после изучения данных.







