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:
- Selecting DOM elements
- Binding data to them
- 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.js | Chart.js | Recharts | Observable Plot | |
|---|---|---|---|---|
| Flexibility | Unlimited | Low | Medium | Medium |
| Learning curve | Steep | Gentle | Medium | Gentle |
| Bundle size | Modular (~50KB) | 200KB | 300KB+ | ~60KB |
| Custom charts | Yes | Limited | Limited | Limited |
| React integration | Manual | Plugin | Native | Plugin |
| Best for | Custom, complex | Common charts | React apps | Quick 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