D3.js Practical Guide

To D3.js Mastery

By Hank Kim

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

  1. Structure your code: Set up margins, scales, and basic structure before handling data
  2. Use semantic grouping: Create <g> elements for logical chart components
  3. Handle data loading errors: Always include error handling for data loading
  4. Consider performance: For large datasets, consider canvas rendering or data aggregation
  5. 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>