Circular packing with D3.js and React
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.
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 nodesstrength
defines how packed the nodes will be, higher the strength the more the nodes will be far away from each otherradius
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.