Collapsible force layout
This chart displays a hierarchical dataset where nodes can be expanded or collapsed by clicking, revealing or hiding their child nodes. The graph is interactive, allowing users to drag nodes and observe the force-directed layout adjusting in real-time.
const width = 960;
const height = 500;
const svg = d3
.create("svg")
.attr("viewBox", [-width / 2, -height / 2, width, height])
.attr("width", width)
.attr("height", height);
display(svg.node());
const simulation = d3
.forceSimulation(nodes)
.force("link", d3.forceLink(links).distance(80))
.force("repulse", d3.forceManyBody().strength(-120))
.force("center", d3.forceCenter())
.on("tick", tick);
const link = svg.append("g").selectAll(".link").data(links).join("line").attr("class", "link");
const node = svg
.append("g")
.selectAll(".node")
.data(nodes)
.join("g")
.attr("class", "node")
.on("click", click) // todo disambiguate with drag
.call(d3.drag().on("start", dragstarted).on("drag", dragged).on("end", dragended));
node
.append("circle")
.attr("r", (d) => Math.sqrt(d.data.value) / 4 || 12)
.style("fill", color);
node
.append("text")
.attr("dy", ".35em")
.text((d) => d.data.name);
node.attr("opacity", 0).transition().attr("opacity", 1);
function tick() {
link
.attr("x1", (d) => d.source.x)
.attr("y1", (d) => d.source.y)
.attr("x2", (d) => d.target.x)
.attr("y2", (d) => d.target.y);
node.attr("transform", (d) => `translate(${d.x},${d.y})`);
}
function color(d) {
return d._children
? "#3182bd" // collapsed package
: d.children
? "#c6dbef" // expanded package
: "#fd8d3c"; // leaf node
}
// Toggle children on click.
function hide(node) {
node.hidden = true;
node.children?.forEach(hide);
}
function show(node) {
delete node.hidden;
node.children?.forEach(show);
}
function click(event, d) {
if (d.children) {
d.children.forEach(hide);
d._children = d.children;
d.children = null;
} else {
d.children = d._children;
d.children?.forEach(show);
d._children = null;
}
node.transition().attr("opacity", (d) => (d.hidden ? 0 : 1));
link.transition().attr("opacity", (d) => (d.target.hidden ? 0 : 1));
simulation.nodes(nodes.filter((d) => !d.hidden));
node.selectAll("circle").style("fill", color);
simulation.alphaTarget(0.3).restart();
}
// Reheat the simulation when drag starts, and fix the subject position.
function dragstarted(event) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}
// Update the subject (dragged node) position during drag.
function dragged(event) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}
// Restore the target alpha so the simulation cools after dragging ends.
// Unfix the subject position now that it’s no longer being dragged.
function dragended(event) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}
// When this cell is re-run, stop the previous simulation. (This doesn’t
// really matter since the target alpha is zero and the simulation will
// stop naturally, but it’s a good practice.)
invalidation.then(() => simulation.stop());
<style>
.node {
cursor: pointer;
}
.node circle {
stroke: #3182bd;
stroke-width: 1.5px;
}
.node text {
font: 10px sans-serif;
user-select:none;
text-anchor: middle;
fill: var(--theme-foreground);
stroke: var(--theme-background);
stroke-width: 4px;
paint-order: stroke;
}
line.link {
fill: none;
stroke: #9ecae1;
stroke-width: 1.5px;
}
</style>
The data
Note that the visualization mutates the nodes by adding e.g. position and speed information.
const {nodes, links} = await FileAttachment("/data/flare.json")
.json()
.then((root) => {
root.children.splice(1, 10); // remove all branches but one
root = d3.hierarchy(root).sum((d) => d.value);
return {nodes: root.descendants(), links: root.links()};
});