Разработка интерактивных дашбордов на D3.js

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка интерактивных дашбордов на D3.js
Сложная
~2-4 недели
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1214
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    852
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    823
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    815

Разработка интерактивных дашбордов на 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 недель. Сложные карты (геовизуализация, проекции) — отдельная оценка после изучения данных.