Interactive Dashboards on D3.js

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

D3.js Interactive Dashboards Development

D3.js is not a charting library. It's a tool for binding data to DOM and managing SVG, Canvas, and HTML. Most off-the-shelf libraries (Chart.js, Recharts, Highcharts) are built on top of D3 or use similar concepts, but offer fixed chart types. D3 is a level below: for visualizing non-standard things or gaining full control over interactivity.

When to Use D3 vs Pre-built Libraries

Recharts or Chart.js is the right choice for standard tasks: line trends, bar charts, pies. Install, three props, done.

D3 is justified when:

  • Custom projections needed (maps, radar charts with non-linear axes)
  • Interactivity requires precise state management (brush selection, synchronized zoom)
  • Animations tied to data through transitions
  • Multiple linked visualizations with shared state (brushing & linking)

Project Structure

src/
├── visualizations/
│   ├── core/
│   │   ├── scales.ts          # reusable d3 scales
│   │   ├── axes.ts            # formatted axes
│   │   └── tooltip.ts         # unified tooltip manager
│   ├── charts/
│   │   ├── LineChart.tsx
│   │   ├── BarChart.tsx
│   │   └── ScatterPlot.tsx
│   └── dashboard/
│       ├── Dashboard.tsx
│       └── useDashboardState.ts

Basic Pattern: D3 Inside React

Two approaches. First—D3 fully manages DOM, React only renders container:

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(); // clear before redraw

    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 with 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',
        }}
      />
    </>
  );
}

Second approach—React renders SVG elements, D3 used only for computations:

// Only d3-scale, d3-array, d3-shape—no DOM manipulation
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>
  );
}

Second approach integrates better with React DevTools, easier to test, but first gives more control over transitions and complex interactions.

Zoom and Brush

The two most sought interactions in analytics dashboards.

// Zoom with X-axis synchronization
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 for range selection
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);
}

Performance with Large Datasets

D3 + SVG starts lagging after ~5000 points. Solutions:

Canvas instead of SVG for scatter plots with thousands of points:

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—data thinning before render. Chart.js has a built-in plugin; for D3 implement manually via LTTB (Largest Triangle Three Buckets):

// Simplified 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;
}

Syncing Multiple Charts

Classic pattern for analytics dashboards—brushing & linking: range selection on one chart filters data on all others.

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>
  );
}

Export SVG to 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;
}

Responsiveness

Dashboard should work on various screens. Pattern with 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>
  );
}

Timeline

Single custom interactive chart (tooltip, zoom)—2–4 days. Full analytics dashboard with 4–6 linked visualizations, filters, syncing, and export—3–5 weeks. Complex maps (geo visualization, projections)—separate estimate after data review.