SVG with D3.js

D3 (Data-Driven Documents) provides a powerful API for manipulating SVG elements.

Grid Pattern

html
<svg id="gridSvg" style="border: 1px solid gray"></svg>
js
import * as d3 from '/lib/d3.esm.js';

const svg = d3.select('#gridSvg');
svg.attr('width', '100%');

// Grid configuration
const extent = 200;
const majorStep = 50;
const minorStep = 10;
const yUp = true;

// Compute viewBox
const padding = 40;
const viewMin = -extent - padding;
const viewSize = (extent + padding) * 2;
svg.attr('viewBox', `${viewMin} ${viewMin} ${viewSize} ${viewSize}`)
   .attr('preserveAspectRatio', 'xMidYMid meet');

// Create scales - these map data values to pixel positions
// For yUp, we invert the y scale so positive values go up
const xScale = d3.scaleLinear().domain([-extent, extent]).range([-extent, extent]);
const yScale = d3.scaleLinear().domain([-extent, extent]).range(yUp ? [extent, -extent] : [-extent, extent]);

// Create axes using d3.axis generators
const xAxis = d3.axisBottom(xScale)
    .tickValues(d3.range(-extent, extent + 1, majorStep).filter(v => v !== 0))
    .tickSize(extent * 2)  // extend ticks across the full grid height
    .tickPadding(5);

const yAxis = d3.axisLeft(yScale)
    .tickValues(d3.range(-extent, extent + 1, majorStep).filter(v => v !== 0))
    .tickSize(extent * 2)  // extend ticks across the full grid width
    .tickPadding(5);

// Grid layer with background
const gridLayer = svg.append('g').attr('id', 'gridLayer');

gridLayer.append('rect')
    .attr('x', -extent).attr('y', -extent)
    .attr('width', extent * 2).attr('height', extent * 2)
    .attr('fill', '#fafafa').attr('stroke', '#333').attr('stroke-width', 1);

// X-axis grid lines (rendered as ticks)
const xAxisGroup = gridLayer.append('g')
    .attr('class', 'x-axis')
    .attr('transform', `translate(0, ${-extent})`)
    .call(xAxis);

// Style the grid lines
xAxisGroup.selectAll('.tick line')
    .attr('stroke', '#a0a0a0')
    .attr('stroke-width', 0.5);

xAxisGroup.selectAll('.tick text')
    .attr('y', extent * 2 + 15)
    .attr('font-size', 8);

xAxisGroup.select('.domain').remove(); // remove the axis line

// Y-axis grid lines
const yAxisGroup = gridLayer.append('g')
    .attr('class', 'y-axis')
    .attr('transform', `translate(${extent}, 0)`)
    .call(yAxis);

yAxisGroup.selectAll('.tick line')
    .attr('stroke', '#a0a0a0')
    .attr('stroke-width', 0.5);

yAxisGroup.selectAll('.tick text')
    .attr('x', -extent * 2 - 10)
    .attr('font-size', 8);

yAxisGroup.select('.domain').remove();

// Colored axis lines through origin
gridLayer.append('line')
    .attr('x1', -extent).attr('y1', 0).attr('x2', extent).attr('y2', 0)
    .attr('stroke', '#e74c3c').attr('stroke-width', 1.5).attr('opacity', 0.7);

gridLayer.append('line')
    .attr('x1', 0).attr('y1', -extent).attr('x2', 0).attr('y2', extent)
    .attr('stroke', '#27ae60').attr('stroke-width', 1.5).attr('opacity', 0.7);

// Origin marker
gridLayer.append('circle')
    .attr('cx', 0).attr('cy', 0).attr('r', 4)
    .attr('fill', '#e74c3c').attr('opacity', 0.8);

gridLayer.append('text')
    .attr('x', 8).attr('y', -8)
    .attr('font-size', 10).attr('fill', '#333')
    .text('(0,0)');

// Content layer - use yScale for positioning when yUp is true
const contentLayer = svg.append('g')
    .attr('id', 'contentLayer');

// Helper to add text at data coordinates
function addText(parent, x, y, text) {
  const t = parent.append('text')
      .attr('x', xScale(x)).attr('y', yScale(y))
      .attr('font-size', 8).attr('text-anchor', 'middle')
      .text(text);
  return t;
}

// Store shared context on window for use in other code blocks
window.grid = {
  svg,
  contentLayer,
  xScale,
  yScale,
  addText,
  extent,
  majorStep,
  yUp
};

Adding Shapes with D3

D3's method chaining makes it easy to create and style elements. Use the scales to position elements:

js
import * as d3 from '/lib/d3.esm.js';
const { contentLayer, xScale, yScale } = window.grid;

// Circle at (50, 50) in data coordinates
contentLayer.append('circle')
    .attr('cx', xScale(50))
    .attr('cy', yScale(50))
    .attr('r', 20)
    .attr('fill', 'blue');

// Rectangle from (-50, 0) to (0, 50)
contentLayer.append('rect')
    .attr('x', xScale(-50))
    .attr('y', yScale(50))  // note: y is top edge, so use higher y value
    .attr('width', 50)
    .attr('height', 50)
    .attr('fill', 'none').attr('stroke', 'red').attr('stroke-width', 2);

Polygons and Paths

D3's d3.lineRadial and d3.line generators create path data from points:

js
import * as d3 from '/lib/d3.esm.js';
const { contentLayer, xScale, yScale, addText } = window.grid;

// Use d3.lineRadial for regular polygons
const polygonData = [
  { sides: 3, x: -150, y: 100, color: '#e74c3c' },
  { sides: 4, x: -50, y: 100, color: '#e67e22' },
  { sides: 5, x: 50, y: 100, color: '#27ae60' },
  { sides: 6, x: 150, y: 100, color: '#3498db' }
];

// d3.lineRadial generates path from angle/radius pairs
const radialLine = d3.lineRadial()
    .angle((d, i, arr) => (i * 2 * Math.PI / arr.length) - Math.PI / 2)
    .radius(15)
    .curve(d3.curveLinearClosed);

// Polygon versions using radial line generator
polygonData.forEach(d => {
  const g = contentLayer.append('g')
      .attr('transform', `translate(${xScale(d.x)}, ${yScale(d.y)})`);

  const points = d3.range(d.sides);

  g.append('path')
      .attr('d', radialLine(points))
      .attr('fill', 'none')
      .attr('stroke', d.color)
      .attr('stroke-width', 2);
  addText(contentLayer, d.x, d.y - 25, 'd3.lineRadial');
});

// Path versions using d3.line with explicit points
const line = d3.line().curve(d3.curveLinearClosed);

polygonData.forEach(d => {
  const g = contentLayer.append('g')
      .attr('transform', `translate(${xScale(d.x)}, ${yScale(d.y + 40)})`);

  const points = d3.range(d.sides).map(i => {
    const angle = (i * 2 * Math.PI / d.sides) - Math.PI / 2;
    return [15 * Math.cos(angle), 15 * Math.sin(angle)];
  });

  g.append('path')
      .attr('d', line(points))
      .attr('fill', 'none')
      .attr('stroke', d.color)
      .attr('stroke-width', 2);
  addText(contentLayer, d.x, d.y + 40 - 25, 'd3.line');
});

Bezier Curves with d3.line curves

D3 provides curve interpolators for smooth paths:

js
import * as d3 from '/lib/d3.esm.js';
const { contentLayer, xScale, yScale, addText } = window.grid;

// Sample data points (relative to each curve's center)
const curvePoints = [[-40, 0], [-20, -30], [0, 0], [20, 30], [40, 0]];

// Curve data with positions in data coordinates
const curveData = [
  { x: -150, y: -50, curve: d3.curveBasis, color: '#9b59b6', label: 'd3.curveBasis' },
  { x: -50, y: -50, curve: d3.curveCardinal, color: '#e74c3c', label: 'd3.curveCardinal' },
  { x: -150, y: -120, curve: d3.curveCatmullRom, color: '#3498db', label: 'd3.curveCatmullRom' },
  { x: -50, y: -120, curve: d3.curveNatural, color: '#27ae60', label: 'd3.curveNatural' }
];

curveData.forEach(d => {
  const g = contentLayer.append('g')
      .attr('transform', `translate(${xScale(d.x)}, ${yScale(d.y)})`);

  // Create line generator with the specified curve
  const lineGen = d3.line().curve(d.curve);

  g.append('path')
      .attr('d', lineGen(curvePoints))
      .attr('fill', 'none')
      .attr('stroke', d.color)
      .attr('stroke-width', 2);

  // Show data points
  g.selectAll('circle')
      .data(curvePoints)
      .join('circle')
      .attr('cx', p => p[0])
      .attr('cy', p => p[1])
      .attr('r', 2)
      .attr('fill', d.color)
      .attr('opacity', 0.5);

  addText(contentLayer, d.x, d.y - 20, d.label);
});

Stroke End Caps and Line Joins

js
import * as d3 from '/lib/d3.esm.js';
const { contentLayer, xScale, yScale, addText } = window.grid;

// End caps using data binding - positions in data coordinates
const capData = [
  { cap: 'butt', x: 50, y: -50, label: 'butt (default)' },
  { cap: 'round', x: 150, y: -50, label: 'round' },
  { cap: 'square', x: 100, y: -100, label: 'square' }
];

const capGroups = contentLayer.selectAll('.cap-group')
    .data(capData)
    .join('g')
    .attr('class', 'cap-group')
    .attr('transform', d => `translate(${xScale(d.x)}, ${yScale(d.y)})`);

capGroups.append('line')
    .attr('x1', -30).attr('y1', 0).attr('x2', 30).attr('y2', 0)
    .attr('stroke', '#333').attr('stroke-width', 10)
    .attr('stroke-linecap', d => d.cap);

capGroups.each(function(d) {
  addText(contentLayer, d.x, d.y - 20, d.label);
});

// Line joins using data binding - positions in data coordinates
const joinData = [
  { join: 'miter', x: 50, y: -150, label: 'miter (default)' },
  { join: 'round', x: 150, y: -150, label: 'round' },
  { join: 'bevel', x: 100, y: -190, label: 'bevel' }
];

const joinGroups = contentLayer.selectAll('.join-group')
    .data(joinData)
    .join('g')
    .attr('class', 'join-group')
    .attr('transform', d => `translate(${xScale(d.x)}, ${yScale(d.y)})`);

joinGroups.append('path')
    .attr('d', 'M -20,10 L 0,-10 L 20,10')
    .attr('fill', 'none').attr('stroke', '#333').attr('stroke-width', 8)
    .attr('stroke-linejoin', d => d.join);

joinGroups.each(function(d) {
  addText(contentLayer, d.x, d.y - 25, d.label);
});  

Pan and Zoom

D3's d3-zoom module provides pan and zoom behavior for SVG elements. Use mouse wheel to zoom, click and drag to pan. On touch devices, use pinch to zoom and drag to pan.

js
import * as d3 from '/lib/d3.esm.js';
const { svg, extent } = window.grid;

// Create a container group for all zoomable content
const zoomLayer = svg.insert('g', ':first-child').attr('id', 'zoomLayer');

// Move gridLayer and contentLayer into zoomLayer
zoomLayer.node().appendChild(svg.select('#gridLayer').node());
zoomLayer.node().appendChild(svg.select('#contentLayer').node());

// Create zoom behavior
const zoom = d3.zoom()
    .scaleExtent([0.5, 10])  // min/max zoom scale
    .on('zoom', (event) => {
      zoomLayer.attr('transform', event.transform);
    });

// Apply zoom behavior to the SVG
svg.call(zoom);

// Double-click to reset zoom
svg.on('dblclick.zoom', () => {
  svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
});

© 2026 simpatico