본문으로 건너뛰기 D3.js Complete Guide | Data Visualization for the Web

D3.js Complete Guide | Data Visualization for the Web

D3.js Complete Guide | Data Visualization for the Web

이 글의 핵심

D3.js gives you the building blocks — SVG, scales, axes, layouts — to build any chart imaginable. This guide covers the core concepts with complete working examples: bar charts, line charts, scatter plots, and interactive transitions.

D3.js Core Concepts

D3 (Data-Driven Documents) works by:

  1. Selecting DOM elements
  2. Binding data to them
  3. Transforming elements based on data (enter/update/exit)
Data → Scale → SVG Element
[10, 20, 50] → scaleLinear → rect height="10" height="20" height="50"

Setup

npm install d3
npm install --save-dev @types/d3  # TypeScript types
<!-- Or CDN for quick prototyping -->
<script src="https://d3js.org/d3.v7.min.js"></script>

1. SVG Basics

D3 draws into SVG. Understanding SVG coordinates:

(0,0)───────────────→ x

  │   SVG coordinate system
  │   origin is top-left
  │   y increases downward
  ↓ y
// Create SVG container
const width = 800
const height = 400
const margin = { top: 20, right: 30, bottom: 40, left: 50 }

const svg = d3.select('#chart')
  .append('svg')
  .attr('width', width)
  .attr('height', height)

// Inner chart area (accounting for margins)
const chart = svg.append('g')
  .attr('transform', `translate(${margin.left},${margin.top})`)

const innerWidth = width - margin.left - margin.right    // 720
const innerHeight = height - margin.top - margin.bottom  // 340

2. Scales — The Core of D3

Scales map data values to visual values (pixels, colors):

// Linear scale: numbers → pixels
const yScale = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.value)])  // data range
  .range([innerHeight, 0])  // pixel range (inverted: 0 is bottom)
  .nice()  // round to nice values

yScale(0)    // → innerHeight (bottom)
yScale(100)  // → 0 (top)
yScale(50)   // → innerHeight / 2

// Band scale: categories → bar positions
const xScale = d3.scaleBand()
  .domain(data.map(d => d.name))  // ['Jan', 'Feb', 'Mar', ...]
  .range([0, innerWidth])
  .padding(0.2)  // 20% padding between bars

xScale('Jan')         // → x position of Jan bar
xScale.bandwidth()    // → width of each bar

// Time scale
const timeScale = d3.scaleTime()
  .domain([new Date('2024-01-01'), new Date('2024-12-31')])
  .range([0, innerWidth])

// Color scale
const colorScale = d3.scaleOrdinal()
  .domain(['A', 'B', 'C'])
  .range(['#ff6b6b', '#4ecdc4', '#45b7d1'])

3. Bar Chart

const data = [
  { month: 'Jan', sales: 4200 },
  { month: 'Feb', sales: 3800 },
  { month: 'Mar', sales: 5100 },
  { month: 'Apr', sales: 4700 },
  { month: 'May', sales: 5300 },
  { month: 'Jun', sales: 6200 },
]

const width = 600, height = 400
const margin = { top: 20, right: 20, bottom: 40, left: 60 }
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom

const svg = d3.select('#bar-chart')
  .append('svg')
  .attr('width', width)
  .attr('height', height)

const g = svg.append('g')
  .attr('transform', `translate(${margin.left},${margin.top})`)

// Scales
const x = d3.scaleBand()
  .domain(data.map(d => d.month))
  .range([0, innerWidth])
  .padding(0.3)

const y = d3.scaleLinear()
  .domain([0, d3.max(data, d => d.sales) * 1.1])
  .range([innerHeight, 0])

// Axes
g.append('g')
  .attr('transform', `translate(0,${innerHeight})`)
  .call(d3.axisBottom(x))

g.append('g')
  .call(d3.axisLeft(y).tickFormat(d => `$${d3.format(',')(d)}`))

// Bars
g.selectAll('rect')
  .data(data)
  .join('rect')
  .attr('x', d => x(d.month))
  .attr('y', d => y(d.sales))
  .attr('width', x.bandwidth())
  .attr('height', d => innerHeight - y(d.sales))
  .attr('fill', '#4ecdc4')
  .attr('rx', 4)  // rounded corners

4. Line Chart

const timeData = [
  { date: new Date('2024-01-01'), value: 100 },
  { date: new Date('2024-02-01'), value: 120 },
  { date: new Date('2024-03-01'), value: 115 },
  { date: new Date('2024-04-01'), value: 140 },
  { date: new Date('2024-05-01'), value: 135 },
  { date: new Date('2024-06-01'), value: 160 },
]

// Scales
const x = d3.scaleTime()
  .domain(d3.extent(timeData, d => d.date))
  .range([0, innerWidth])

const y = d3.scaleLinear()
  .domain([0, d3.max(timeData, d => d.value) * 1.1])
  .range([innerHeight, 0])

// Line generator
const line = d3.line()
  .x(d => x(d.date))
  .y(d => y(d.value))
  .curve(d3.curveMonotoneX)  // smooth curve

// Area (gradient fill under line)
const area = d3.area()
  .x(d => x(d.date))
  .y0(innerHeight)
  .y1(d => y(d.value))
  .curve(d3.curveMonotoneX)

// Draw area (fill under curve)
g.append('path')
  .datum(timeData)
  .attr('fill', 'rgba(78, 205, 196, 0.2)')
  .attr('d', area)

// Draw line
g.append('path')
  .datum(timeData)
  .attr('fill', 'none')
  .attr('stroke', '#4ecdc4')
  .attr('stroke-width', 2.5)
  .attr('d', line)

// Data points
g.selectAll('circle')
  .data(timeData)
  .join('circle')
  .attr('cx', d => x(d.date))
  .attr('cy', d => y(d.value))
  .attr('r', 5)
  .attr('fill', '#4ecdc4')
  .attr('stroke', 'white')
  .attr('stroke-width', 2)

5. Tooltips

// Create tooltip element
const tooltip = d3.select('body')
  .append('div')
  .attr('class', 'tooltip')
  .style('position', 'absolute')
  .style('background', 'rgba(0,0,0,0.8)')
  .style('color', 'white')
  .style('padding', '8px 12px')
  .style('border-radius', '4px')
  .style('font-size', '13px')
  .style('pointer-events', 'none')
  .style('opacity', 0)

// Add tooltip to bars
g.selectAll('rect')
  .data(data)
  .join('rect')
  // ... attrs ...
  .on('mouseover', (event, d) => {
    tooltip
      .style('opacity', 1)
      .html(`
        <strong>${d.month}</strong><br/>
        Sales: $${d3.format(',')(d.sales)}
      `)
  })
  .on('mousemove', (event) => {
    tooltip
      .style('left', `${event.pageX + 12}px`)
      .style('top', `${event.pageY - 28}px`)
  })
  .on('mouseout', () => {
    tooltip.style('opacity', 0)
  })

6. Transitions (Animation)

// Animate bars on load
g.selectAll('rect')
  .data(data)
  .join('rect')
  .attr('x', d => x(d.month))
  .attr('width', x.bandwidth())
  .attr('y', innerHeight)      // start from bottom
  .attr('height', 0)           // start with height 0
  .attr('fill', '#4ecdc4')
  .transition()                // start transition
  .duration(800)
  .delay((d, i) => i * 100)   // stagger bars
  .ease(d3.easeCubicOut)
  .attr('y', d => y(d.sales))
  .attr('height', d => innerHeight - y(d.sales))

// Update chart with new data
function updateChart(newData) {
  const y = d3.scaleLinear()
    .domain([0, d3.max(newData, d => d.value)])
    .range([innerHeight, 0])

  g.selectAll('rect')
    .data(newData)
    .join(
      enter => enter.append('rect')   // new elements
        .attr('fill', '#4ecdc4')
        .attr('y', innerHeight)
        .attr('height', 0),
      update => update,                // existing elements
      exit => exit                     // removed elements
        .transition().duration(300)
        .attr('height', 0)
        .attr('y', innerHeight)
        .remove()
    )
    .transition().duration(500)
    .attr('x', d => x(d.name))
    .attr('width', x.bandwidth())
    .attr('y', d => y(d.value))
    .attr('height', d => innerHeight - y(d.value))
}

7. Scatter Plot with Brushing

const scatterData = d3.range(100).map(() => ({
  x: Math.random() * 100,
  y: Math.random() * 100,
  category: ['A', 'B', 'C'][Math.floor(Math.random() * 3)],
}))

const color = d3.scaleOrdinal()
  .domain(['A', 'B', 'C'])
  .range(['#ff6b6b', '#4ecdc4', '#45b7d1'])

const xScale = d3.scaleLinear().domain([0, 100]).range([0, innerWidth])
const yScale = d3.scaleLinear().domain([0, 100]).range([innerHeight, 0])

// Draw points
const dots = g.selectAll('circle')
  .data(scatterData)
  .join('circle')
  .attr('cx', d => xScale(d.x))
  .attr('cy', d => yScale(d.y))
  .attr('r', 6)
  .attr('fill', d => color(d.category))
  .attr('opacity', 0.7)

// Add brush for selection
const brush = d3.brush()
  .extent([[0, 0], [innerWidth, innerHeight]])
  .on('brush end', ({ selection }) => {
    if (!selection) {
      dots.attr('opacity', 0.7)
      return
    }
    const [[x0, y0], [x1, y1]] = selection
    dots.attr('opacity', d => {
      const cx = xScale(d.x), cy = yScale(d.y)
      return x0 <= cx && cx <= x1 && y0 <= cy && cy <= y1 ? 1 : 0.2
    })
  })

g.append('g').call(brush)

8. React + D3 Integration

import { useEffect, useRef } from 'react'
import * as d3 from 'd3'

interface BarChartProps {
  data: { name: string; value: number }[]
  width?: number
  height?: number
}

function BarChart({ data, width = 500, height = 300 }: BarChartProps) {
  const svgRef = useRef<SVGSVGElement>(null)

  useEffect(() => {
    if (!svgRef.current || !data.length) return

    const margin = { top: 20, right: 20, bottom: 40, left: 50 }
    const innerWidth = width - margin.left - margin.right
    const innerHeight = height - margin.top - margin.bottom

    // Clear previous render
    d3.select(svgRef.current).selectAll('*').remove()

    const svg = d3.select(svgRef.current)
    const g = svg.append('g')
      .attr('transform', `translate(${margin.left},${margin.top})`)

    const x = d3.scaleBand()
      .domain(data.map(d => d.name))
      .range([0, innerWidth])
      .padding(0.3)

    const y = d3.scaleLinear()
      .domain([0, d3.max(data, d => d.value)!])
      .range([innerHeight, 0])
      .nice()

    g.append('g')
      .attr('transform', `translate(0,${innerHeight})`)
      .call(d3.axisBottom(x))

    g.append('g')
      .call(d3.axisLeft(y))

    g.selectAll('rect')
      .data(data)
      .join('rect')
      .attr('x', d => x(d.name)!)
      .attr('y', d => y(d.value))
      .attr('width', x.bandwidth())
      .attr('height', d => innerHeight - y(d.value))
      .attr('fill', '#4ecdc4')
      .attr('rx', 3)

  }, [data, width, height])

  return <svg ref={svgRef} width={width} height={height} />
}

// Usage
const data = [
  { name: 'Jan', value: 4200 },
  { name: 'Feb', value: 3800 },
  { name: 'Mar', value: 5100 },
]

<BarChart data={data} width={600} height={350} />

D3 vs Chart Libraries

D3.jsChart.jsRechartsObservable Plot
FlexibilityUnlimitedLowMediumMedium
Learning curveSteepGentleMediumGentle
Bundle sizeModular (~50KB)200KB300KB+~60KB
Custom chartsYesLimitedLimitedLimited
React integrationManualPluginNativePlugin
Best forCustom, complexCommon chartsReact appsQuick exploration

Key Takeaways

  • Scales are the foundation — they map data values to pixels/colors
  • selection.join() handles enter/update/exit for data-driven DOM updates
  • Margins pattern: draw inside a <g> translated by margin to leave room for axes
  • Transitions animate with .transition().duration().attr()
  • React integration: let D3 manage SVG in useEffect, or use D3 only for math and let React render
  • Import only what you need: import { scaleLinear, axisBottom } from 'd3' for smaller bundles