Circular packing with D3.js and React

Visualization Framework/Tools

This post describes building a circular packing chart using D3.js’s force layout and React library.

First, we will create a component CircularPackGraph component and draw the svg.

import { useRef, useEffect } from "react";

export default function CircularPackGraph({data, width, height}) {
  const canvasRef = useRef()
  return (
      <svg ref={canvasRef} width={width} height={height}/>
  )
}
svg {
    background: #d9e2ee;
}

An empty svg will be rendered

Next, we will add circles inside this svg

const addCirclePacking = () => {
    const canvas = d3.select(canvasRef.current)
    const scale = d3.scaleLinear().domain([0, 1000]).range([5, 80])
    const colorSpectrum = d3.scaleLinear().domain([0, 1000]).range(['#EC464F', '#ACECF7'])
    let nodes = canvas.append('g')
      .selectAll('circle')
      .data(data)
      .enter()
      .append('circle')
      .attr('r', (d) => scale(d.size))
      .attr('cx', width / 2)
      .attr('cy', height / 2)
      .style("fill", (d) => colorSpectrum(d.size))
      .attr('stroke', "#000 ")
      .style('stroke-width', 2)
}

First, we used d3.select() to turn svg into d3 selection object.Using append we added the g element and inside it the circles.

  • .selectAll('circles') defines the type of elements that will be mapped to each data point, it's also for adding style and attributes.
  • .data(data) defines the array of data.
  • enter() function is like a placeholder for DOM element that needs to be created for each data point.In our case, circle elements needs to be added to the DOM for each data.
  • attr defines position(cx, cy) and radius(r) of each circle
const scale = d3.scaleLinear().domain([0, 1000]).range([5, 80])

scaleLinear() normalizes data to a given range.We have mapped the radius of the circle to be within [5, 80] for any value between [0, 1000] that is present in our data.

On render, we will see all circles are stacked on top of each other, since we have given same coordinate to each circle.

To prevent this overlapping of circles, we will add the D3's force simulation.
let simulation = d3.forceSimulation()
  .force('center', d3.forceCenter((width / 2),(height / 2))) // Attraction to the center of the svg area
  .force('charge', d3.forceManyBody().strength(0.1)) // Nodes are attracted to one each other
  .force('collide', d3.forceCollide().strength(0.05).radius((d) => scale(d.size))) // prevents overlapping

simulation.nodes(data)
  .on('tick', (d) => {
    nodes
      .attr('cx', (d) => d.x)
      .attr('cy', (d) => d.y)
  });
  • .force('center', d3.forceCenter((width / 2),(height / 2)))) attracts all the nodes at a specific point, in our case at the center of the svg.
  • force('charge', d3.forceManyBody().strength(0.1)), forceManyBody causes nodes to attract or repel one another. If the value of strength is positive, the nodes attract and for negative values they repel.
  • force('collide', d3.forceCollide().strength(0.2).radius((d) => scale(d.size) + 2))
    • forceCollide prevents overlapping of nodes
    • strength defines how packed the nodes will be, higher the strength the more the nodes will be far away from each other
    • radius is specified by a function which normalizes the value from the data

Finally, the simulation is applied on all the nodes. At each iteration(tick), the position of each node is updated through a callback function.