D3.js Practical Guide
To D3.js Mastery
D3.js Practical Guide: Data Visualization for Developers
When I first encountered D3.js, I wondered why anyone would choose it over simpler charting libraries like Chart.js. The API seemed unnecessarily complex for creating basic charts. After working with it on several projects, I understand why D3.js remains the go-to choice for custom data visualizations.
Why D3.js?
D3.js stands for Data-Driven Documents. The core concept is binding data to DOM elements so that when your data changes, the visual representation updates automatically.
// This is the fundamental D3.js pattern
const svg = d3.select("svg");
svg
.selectAll("circle")
.data(sampleData) // Bind data
.enter() // Handle new data points
.append("circle") // Create DOM elements
.attr("r", (d) => d.radius); // Set attributes based on data
Understanding Selections
Important distinction: d3.select()
is not the same as querySelector()
.
// ❌ This won't work for method chaining
const svg = document.querySelector("svg");
svg.selectAll("circle"); // Error!
// ✅ Use D3 selection objects
const svg = d3.select("svg");
svg.selectAll("circle"); // Works correctly
D3 selections aren’t just DOM elements - they’re special objects designed for data binding and manipulation.
Data Join Fundamentals
The data join is where D3.js gets confusing for newcomers. Let me break down what actually happens:
const circles = svg
.selectAll("circle") // Select all circles (even if none exist)
.data(dataset); // Bind data to selection
// At this point, three things can happen:
// 1. UPDATE: Existing elements that have matching data
// 2. ENTER: New data points that need new elements
// 3. EXIT: Existing elements that no longer have data
The Classic Pattern (Before .join())
Here’s how we used to handle data joins:
const circles = svg.selectAll("circle").data(dataset);
// Handle existing elements (UPDATE)
circles.attr("r", (d) => d.radius).attr("fill", (d) => d.color);
// Handle new data (ENTER)
const enterSelection = circles
.enter()
.append("circle")
.attr("r", (d) => d.radius)
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("fill", (d) => d.color);
// Combine for future updates
const merged = circles.merge(enterSelection);
// Handle removed data (EXIT)
circles.exit().remove();
The Modern Approach with .join()
D3 v5+ introduced .join()
to simplify this pattern:
svg
.selectAll("circle")
.data(dataset)
.join("circle") // Handles enter, update, and exit automatically
.attr("r", (d) => d.radius)
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("fill", (d) => d.color);
For custom behavior:
svg
.selectAll("circle")
.data(dataset)
.join(
(enter) =>
enter
.append("circle")
.attr("fill", "green")
.attr("r", 0)
.transition()
.attr("r", (d) => d.radius),
(update) => update.attr("fill", "blue"),
(exit) => exit.transition().attr("r", 0).remove()
);
Scales: Converting Data to Visual Space
Scales translate your data values into visual dimensions. Think of them as conversion functions.
scaleLinear for Continuous Data
const xScale = d3
.scaleLinear()
.domain([0, 100]) // Input range (your data)
.range([0, 500]); // Output range (pixels)
console.log(xScale(50)); // Returns 250
scaleBand for Categorical Data
const xScale = d3
.scaleBand()
.domain(["A", "B", "C", "D"])
.range([0, 400])
.padding(0.1);
console.log(xScale("B")); // Returns the x position for category B
console.log(xScale.bandwidth()); // Returns the width of each band
Building a Bar Chart: Step by Step
Here’s a complete example that demonstrates the core concepts:
// Setup
const margin = { top: 20, right: 30, bottom: 40, left: 40 };
const width = 800 - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const svg = d3
.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom);
const g = svg
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Load and process data
d3.json("data.json").then((data) => {
// Create scales
const xScale = d3
.scaleBand()
.domain(data.map((d) => d.category))
.range([0, width])
.padding(0.1);
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.value)])
.range([height, 0]); // Note: reversed for SVG coordinate system
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3.axisLeft(yScale);
g.append("g").attr("transform", `translate(0,${height})`).call(xAxis);
g.append("g").call(yAxis);
// Create bars
g.selectAll(".bar")
.data(data)
.join("rect")
.attr("class", "bar")
.attr("x", (d) => xScale(d.category))
.attr("y", (d) => yScale(d.value))
.attr("width", xScale.bandwidth())
.attr("height", (d) => height - yScale(d.value))
.attr("fill", "steelblue");
});
Common Pitfalls and Solutions
1. SVG Coordinate System
SVG coordinates start from the top-left corner. For charts, you typically want higher values to appear higher on screen:
// This is why we often reverse the range for y-scales
const yScale = d3.scaleLinear().domain([0, maxValue]).range([height, 0]); // height to 0, not 0 to height
2. Missing Required Attributes
Some SVG elements won’t render without specific attributes:
// Circles need r, cx, cy
circle.attr("r", 5).attr("cx", 10).attr("cy", 20);
// Rectangles need width and height
rect.attr("width", 50).attr("height", 30);
3. Data Type Issues
D3 scales expect consistent data types:
// Make sure numeric data is actually numeric
data.forEach((d) => {
d.value = +d.value; // Convert string to number
});
Generators, Components, and Layouts
D3 provides utilities beyond basic data binding:
Generators
Create SVG path strings from data:
const line = d3
.line()
.x((d) => xScale(d.date))
.y((d) => yScale(d.value))
.curve(d3.curveMonotoneX);
svg
.append("path")
.datum(data)
.attr("d", line)
.attr("fill", "none")
.attr("stroke", "steelblue");
Components
Pre-built interactive elements:
// Axes are components
const xAxis = d3.axisBottom(xScale).ticks(5).tickFormat(d3.timeFormat("%b"));
g.append("g").call(xAxis);
Layouts
Transform data for specific chart types:
const pie = d3.pie().value((d) => d.value);
const arc = d3.arc().innerRadius(0).outerRadius(100);
const pieData = pie(data);
// Now pieData has startAngle, endAngle properties for each slice
Best Practices
- Structure your code: Set up margins, scales, and basic structure before handling data
- Use semantic grouping: Create
<g>
elements for logical chart components - Handle data loading errors: Always include error handling for data loading
- Consider performance: For large datasets, consider canvas rendering or data aggregation
- Make it responsive: Use viewBox and CSS for responsive charts
When to Use D3.js
Choose D3.js when you need:
- Custom visualizations that don’t fit standard chart types
- Fine control over every visual element
- Complex interactions and animations
- Data-driven visual updates
Consider alternatives when:
- You need standard charts quickly (Chart.js, Recharts)
- Your team isn’t comfortable with SVG and lower-level APIs
- Simple visualizations are sufficient for your use case
D3.js has a learning curve, but the flexibility it provides is unmatched for custom data visualization needs.
Bar Graph Full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>D3.js Bar Chart - COVID Cases by Region</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background-color: #f5f5f5;
}
h2 {
color: #333;
text-align: center;
margin-bottom: 30px;
}
.canvas {
display: flex;
justify-content: center;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 20px;
}
/* Hover effect for rectangles - applies to any future graphs too */
rect:hover {
stroke: black;
stroke-width: 2px;
opacity: 0.8;
}
.x-axis text {
font-size: 12px;
}
.y-axis text {
font-size: 12px;
}
.text {
pointer-events: none;
font-weight: bold;
}
</style>
</head>
<body>
<h2>확진자 현황 (COVID Cases by Region)</h2>
<div class="canvas"></div>
<script>
// Chart dimensions and configuration
const SVG_WIDTH = 1000;
const SVG_HEIGHT = 800;
const GRAPH_WIDTH = 800;
const GRAPH_HEIGHT = 600;
const padding = [70, 70];
const RECT_WIDTH = 30;
// Create SVG container
const svg = d3
.select(".canvas")
.append("svg")
.attr("width", SVG_WIDTH)
.attr("height", SVG_HEIGHT);
// Create main graph group with padding
const graph = svg
.append("g")
.attr("transform", `translate(${padding[0]},${padding[1]})`);
// Create axis groups
const gx = graph
.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(0,${GRAPH_HEIGHT})`);
const gy = graph.append("g").attr("class", "y-axis");
// Sample data (replace with actual d3.json call)
const sampleData = [
{ 지역이름: "서울", 확진자수: 1250 },
{ 지역이름: "부산", 확진자수: 890 },
{ 지역이름: "대구", 확진자수: 750 },
{ 지역이름: "인천", 확진자수: 630 },
{ 지역이름: "광주", 확진자수: 420 },
{ 지역이름: "대전", 확진자수: 380 },
{ 지역이름: "울산", 확진자수: 290 },
{ 지역이름: "세종", 확진자수: 150 },
];
// Function to render the chart
function renderChart(data) {
// Create scales
const xScale = d3
.scaleBand()
.domain(data.map((item) => item.지역이름))
.range([0, GRAPH_WIDTH])
.padding(0.1);
// Y scale - higher values should appear higher (lower y coordinate)
const yScale = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.확진자수)])
.range([GRAPH_HEIGHT, 0]);
// Create axes
const xAxis = d3.axisBottom(xScale);
const yAxis = d3
.axisLeft(yScale)
.ticks(5)
.tickFormat((d) => `${d}명`);
// Render axes
gx.call(xAxis);
gy.call(yAxis);
// Create bars
graph
.selectAll("rect")
.data(data)
.enter()
.append("rect")
.attr("x", (d) => xScale(d.지역이름))
.attr("y", (d) => yScale(d.확진자수))
.attr("height", (d) => GRAPH_HEIGHT - yScale(d.확진자수))
.attr("width", xScale.bandwidth())
.attr("fill", "hotpink")
.attr("rx", 2); // Rounded corners
// Add value labels on bars
graph
.selectAll(".text")
.data(data)
.enter()
.append("text")
.attr("x", (d) => xScale(d.지역이름) + xScale.bandwidth() / 2)
.attr("y", (d) => yScale(d.확진자수) - 5)
.text((d) => d.확진자수)
.attr("class", "text")
.style("font-size", "12px")
.attr("text-anchor", "middle")
.attr("fill", "#333");
// Create line generator
const line = d3
.line()
.x((d) => xScale(d.지역이름) + xScale.bandwidth() / 2)
.y((d) => yScale(d.확진자수))
.curve(d3.curveBasis);
// Add trend line
graph
.append("path")
.datum(data)
.attr("fill", "none")
.attr("stroke", "blue")
.attr("stroke-width", "3px")
.attr("d", line)
.style("opacity", 0.8);
// Add circles at data points
graph
.selectAll(".dot")
.data(data)
.enter()
.append("circle")
.attr("class", "dot")
.attr("cx", (d) => xScale(d.지역이름) + xScale.bandwidth() / 2)
.attr("cy", (d) => yScale(d.확진자수))
.attr("r", 4)
.attr("fill", "blue")
.attr("stroke", "white")
.attr("stroke-width", 2);
}
// Render chart with sample data
renderChart(sampleData);
// Uncomment below to use actual JSON data
/*
d3.json("data4.json")
.then((data) => {
// Remove header row if present
[_, ...data] = [...data];
console.log(data);
renderChart(data);
})
.catch((err) => {
console.error("Error loading data:", err);
// Fallback to sample data
renderChart(sampleData);
});
*/
</script>
</body>
</html>