Skip to main content

Interactive Physics Based Tag Cloud

The Final component

Here is what we will be building.

Try playing around with the controls for a minute before we get started.

Data Fetching and Formatting

Expected Data Format

This visualization makes use of the d3-force package, but then we add in some extra goodies like being able to modify the parameters of the simulation on the fly without reloading the whole chart, which is some extra React magic.

However before we get into all the fun stuff, we need data to feed into the model. In my case we have all the tags of my notes. We need to get information on those tags, and then convert it into nodes and links. Here are some TypeScript types to help you understand the shape that we need to get our data into.

// Define type for your nodes
type NodeType = d3.SimulationNodeDatum & {
id: string;
group: string;
size?: number;
};

// Define type for your links
type LinkType = d3.SimulationLinkDatum<NodeType> & {
source: string | NodeType;
target: string | NodeType;
value?: number; // add value for stroke-width calculation
};

interface ForceSimulationProps {
data: {
nodes: NodeType[];
links: LinkType[];
};
}

In short, each tag will be a node. In my case the node.group doesn't matter, but if you want to introduce color coding according to hierarchy you will want to pay attention to that. node.size is going to be whatever metric you want to quantify if you use variable node radius. In my case I calculated how many times a tag occurs (frequency).

The links are going to be instances where multiple tags appeared in a note, indicating that they are related to one another.

It's not possible for me to write a guide on how to get your data into the correct format in every instance, as I'm not sure what you want to model and what your data looks like. But I can show you how I fetched and transformed my data, which is blog tags in a Docusaurus website.

Docusaurus Tag Fetching and Formatting

tip

This next part is only useful for people who are trying to copy this exact process in a Docusaurus website. If you have any other usecase, you'll need to figure out for yourself how to get your data into the format shown above.

To accomplish this in Docusaurus you have to generate a custom plugin, which will allow you to have access to the blog posts metadata, use that data to generate the tag data you need, and then save that data to global state, which you can then access in your components.

custom-blog.js
const blogPluginExports = require('@docusaurus/plugin-content-blog');

const defaultBlogPlugin = blogPluginExports.default;

async function blogPluginExtended(...pluginArgs) {
const blogPluginInstance = await defaultBlogPlugin(...pluginArgs);

return {
// Add all properties of the default blog plugin so existing functionality is preserved
...blogPluginInstance,
/**
* Override the default `contentLoaded` hook to access blog posts data
*/
contentLoaded: async function (data) {
const tags = data.content.blogTags;
const blogPosts = data.content.blogPosts;

// GET TAGS RELATIONAL DATA (nodes, links)
const prepareTagsRelationalData = (posts) => {
let uniqueTags = new Set();

let nodes = [];
let links = [];

// generate set of unique tags
posts.forEach((post) => {
postTags = post.metadata.tags;

postTags.forEach((tag) => {
uniqueTags.add(tag.label);
});
});

// iterate over unique tags and create nodes
uniqueTags.forEach((tag) => {
nodes.push({id: tag, group: 1});
});

// iterate over posts and create links
posts.forEach((post) => {
postTags = post.metadata.tags;

postTags.forEach((tag) => {
const source = tag.label;

postTags.forEach((tag) => {
const target = tag.label;

if (source !== target) {
links.push({source, target});
}
});
});
});

// Convert set to array
nodes = [...nodes];

return {nodes, links};
};

let {nodes, links} = prepareTagsRelationalData(blogPosts);

const removeDuplicateLinks = (links) => {
const uniqueLinks = new Set();

links.forEach(({source, target}) => {
// Create a sorted string representation of the link
// This ensures `{source: "a", target: "b"}` and `{source: "b", target: "a"}` are considered equal
const linkKey = [source, target].sort().join('^');

uniqueLinks.add(linkKey);
});

// Build the cleaned links array from the unique keys in the set
return Array.from(uniqueLinks).map((linkKey) => {
const [source, target] = linkKey.split('^');
return {source, target};
});
};

// Remove duplicate links
links = removeDuplicateLinks(links);

// GET TAGS DATA (tag quantity, label, permalink)
let tagsData = [];
Object.keys(data.content.blogTags).forEach((key) => {
const tag = tags[key];
let tagMeta = {
label: tag.label,
count: tag.items.length,
permalink: tag.permalink,
};
tagsData.push(tagMeta);
});

// Function to add size to nodes based on tag counts
const addSizeToNodes = (nodes, counts) => {
// Create a map from the counts array
const countMap = new Map();

counts.forEach(({label, count}) => {
countMap.set(label, count);
});

// Update the nodes array with the size
nodes.forEach((node) => {
if (countMap.has(node.id)) {
node.size = countMap.get(node.id);
}
});

return nodes;
};

// Add size attribute to each node
nodes = addSizeToNodes(nodes, tagsData);

// SAVE TO GLOBAL STATE
// isolate set global data
const {setGlobalData} = data.actions;
setGlobalData({
tags: JSON.stringify({nodes, links}),
});

// Call the default overridden `contentLoaded` implementation
return blogPluginInstance.contentLoaded(data);
},
};
}

module.exports = {
...blogPluginExports,
default: blogPluginExtended,
};

Force Simulation Component

I'm going to show the whole force simulation component here, but before I do, I think it will be helpful to review my D3 Responsive React TypeScript Boilerplate and D3 React Responsive Chart posts which will help you understand what is going on with the dimensions parts of this chart.

Here is the component

ForceSimulation.tsx
import React, {useEffect, useRef, useState, useMemo} from 'react';
import * as d3 from 'd3';
// components
import ForceSimulationControls from './ForceSimulationControls';
// context hooks
import {useTagCloudContext} from './hooks/useTagCloudControls';

// Define type for your nodes
type NodeType = d3.SimulationNodeDatum & {
id: string;
group: string;
size?: number;
};

// Define type for your links
type LinkType = d3.SimulationLinkDatum<NodeType> & {
source: string | NodeType;
target: string | NodeType;
value?: number; // add value for stroke-width calculation
};

interface Dimensions {
width: number;
height: number;
margins: number;
containerWidth: number | null;
containerHeight: number | null;
}

interface ForceSimulationProps {
data: {
nodes: NodeType[];
links: LinkType[];
};
settings?: {
nodeColor?: string;
};
}

const ForceSimulation = ({data, settings = {}}: ForceSimulationProps) => {
const {
minNodeSize,
centerStrength,
collisionStrength,
linkStrength,
nodeRadius,
sizeBasedNodeRadius,
labels,
} = useTagCloudContext();

const svgRef = useRef<SVGSVGElement | null>(null);
const svgContainer = useRef<HTMLDivElement | null>(null);

const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);

const dimensions: Dimensions = useMemo(() => {
const dimensions = {
width: width,
height: height,
margins: 0,
containerWidth: null,
containerHeight: null,
};

dimensions.containerWidth = dimensions.width - dimensions.margins * 2;
dimensions.containerHeight = dimensions.height - dimensions.margins * 2;
return dimensions;
}, [width, height]);

// listen for container size changes
useEffect(() => {
const getSvgContainerSize = () => {
const newWidth = svgContainer.current?.clientWidth || 0;
setWidth(newWidth);

const newHeight = svgContainer.current?.clientHeight || 0;
setHeight(newHeight);
};
getSvgContainerSize();
window.addEventListener('resize', getSvgContainerSize);
return () => {
window.removeEventListener('resize', getSvgContainerSize);
};
}, []);

const svg = useMemo(() => {
const svgSelection = d3
.select(svgRef.current)
.classed('tag-chart-svg', true)
.attr('width', dimensions.width)
.attr('height', dimensions.height);

// Clear existing elements before rendering new chart
svgSelection.selectAll('*').remove();

return svgSelection;
}, [dimensions]);

const container = useMemo(() => {
return svg
.append('g')
.classed('tag-chart-inner-container', true)
.attr(
'transform',
`translate(${dimensions.width / 2}, ${dimensions.height / 2})`,
);
}, [svg, dimensions]);

const nodes = useMemo(() => {
console.log('minNodeSize', minNodeSize);

const filteredNodes = data.nodes.filter(
(d) => d.size !== undefined && d.size >= (minNodeSize || 0),
);
return filteredNodes.map((d) => ({...d})) as NodeType[];
}, [data, minNodeSize]);

const links = useMemo(() => {
// because nodes can be filtered, we must filter links based on the nodes that remain
const nodeIds = new Set(nodes.map((d) => d.id));
const filteredLinks = data.links.filter(
(d) =>
(typeof d.source === 'string'
? nodeIds.has(d.source)
: nodeIds.has(d.source.id)) &&
(typeof d.target === 'string'
? nodeIds.has(d.target)
: nodeIds.has(d.target.id)),
);
return filteredLinks.map((d) => ({...d})) as LinkType[];
}, [data, nodes]);

const radius = (d: NodeType) => {
if (sizeBasedNodeRadius) {
return d.size || 5;
} else if (nodeRadius) {
return nodeRadius;
} else {
return 5;
}
};

const simulation = useMemo(() => {
return d3
.forceSimulation<NodeType>(nodes)
.force(
'link',
d3
.forceLink<NodeType, LinkType>(links)
.id((d) => d.id)
.strength(linkStrength),
)
.force('charge', d3.forceManyBody().strength(centerStrength))
.force('x', d3.forceX())
.force('y', d3.forceY())
.force(
'collide',
d3
.forceCollide<NodeType>()
.radius((d) => radius(d) + 5)
.strength(collisionStrength),
);
}, [nodes, links, centerStrength, collisionStrength, linkStrength, radius]);

const link = useMemo(() => {
// Remove any previous lines
container.selectAll('.lines').remove();

return container
.append('g')
.classed('lines', true)
.attr('stroke', '#999')
.attr('stroke-opacity', 0.6)
.selectAll('line')
.data(links)
.join('line')
.attr('stroke-width', (d) => Math.sqrt(d.value || 1));
}, [container, links, simulation]);

const node = useMemo(() => {
// Remove any previous circles
container.selectAll('.circles').remove();

return container
.append('g')
.classed('circles', true)
.selectAll('a') // Select anchor elements
.data(nodes)
.join('a') // Create anchor elements for each node
.attr('xlink:href', (d: NodeType) => `/posts/tags/${d.id}`) // Set href to link to the corresponding tag
.append('circle') // Append a circle inside the anchor
.attr('r', radius)
.attr('fill', settings.nodeColor ?? 'black');
}, [container, nodes, simulation, settings.nodeColor]);

const label = useMemo(() => {
// Remove any previous labels
container.selectAll('.labels').remove();

// If labels are disabled, return null
if (!labels) return null;

return container
.append('g')
.classed('labels', true)
.selectAll<SVGTextElement, NodeType>('text')
.data(nodes)
.join('text')
.classed('label', true)
.text((d) => d.id)
.attr('dy', 10); // Adjust this value to position the label below the circles
}, [container, nodes, simulation, labels]);

// drag behavior
useEffect(() => {
type DragEvent = d3.D3DragEvent<SVGCircleElement, NodeType, NodeType>;

function dragstarted(event: DragEvent) {
if (!event.active) simulation.alphaTarget(0.3).restart();
event.subject.fx = event.subject.x;
event.subject.fy = event.subject.y;
}

function dragged(event: DragEvent) {
event.subject.fx = event.x;
event.subject.fy = event.y;
}

function dragended(event: DragEvent) {
if (!event.active) simulation.alphaTarget(0);
event.subject.fx = null;
event.subject.fy = null;
}

node.call(
d3
.drag<SVGCircleElement, NodeType>()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended),
);
}, [node, simulation]);

useEffect(() => {
simulation.on('tick', () => {
link
.attr('x1', (d: LinkType) =>
typeof d.source !== 'string' ? d.source.x ?? 0 : 0,
)
.attr('y1', (d: LinkType) =>
typeof d.source !== 'string' ? d.source.y ?? 0 : 0,
)
.attr('x2', (d: LinkType) =>
typeof d.target !== 'string' ? d.target.x ?? 0 : 0,
)
.attr('y2', (d: LinkType) =>
typeof d.target !== 'string' ? d.target.y ?? 0 : 0,
);

node
.attr('cx', (d: NodeType) => d.x ?? 0)
.attr('cy', (d: NodeType) => d.y ?? 0);

// Update the label positions
if (labels) {
label
.attr('x', (d: NodeType) => d.x ?? 0)
.attr('y', (d: NodeType) => (d.y ?? 0) + 10); // Adjust the y offset to position the label below the circle
}
});
}, [simulation, link, node, label]);

return (
<>
<ForceSimulationControls />
<div className="tag-chart-container">
<div ref={svgContainer} className="tag-chart-svg-container">
<svg ref={svgRef} />
</div>
</div>
</>
);
};

export default ForceSimulation;

One of the things that is really nice about the way we have put this together here, is that everything is separated into a memo or useEffect that only listens to the things that affect it, selectively re-drawing only the parts of the chart which need to change. This pattern is extremely useful for all D3 charts.

This allows us to do things like change the parameters of the simulation, or the radius of the nodes without redrawing the entire SVG which would reset the positions of the nodes and lines every single time you changed something.

Controls

The controls and their states are all relatively straightforward, they are states that are shared with useContext, and the states are managed with some radix ui sliders and toggles.

ForceSimulationControls.tsx
import React from 'react';
import * as Slider from '@radix-ui/react-slider';
import * as Switch from '@radix-ui/react-switch';

// context hooks
import {useTagCloudContext} from './hooks/useTagCloudControls';

const TagCloudControls = () => {
const {
minNodeSize,
setMinNodeSize,
centerStrength,
setCenterStrength,
collisionStrength,
setCollisionStrength,
linkStrength,
setLinkStrength,
nodeRadius,
setNodeRadius,
sizeBasedNodeRadius,
setSizeBasedNodeRadius,
labels,
setLabels,
} = useTagCloudContext();

return (
<form className="tag-chart-controls flex-column">
{/* TAG INSTANCE THRESHOLD */}
<div className="_tag-cloud-control-row flex-row-center">
<label className="mono-label mr-1">{`MIN TAG FREQUENCY (${minNodeSize})`}</label>
<Slider.Root
className="SliderRoot ml-auto"
value={[minNodeSize]}
onValueChange={(value) => setMinNodeSize(value[0])}
min={1}
max={10}
step={1}>
<Slider.Track className="SliderTrack">
<Slider.Range className="SliderRange" />
</Slider.Track>
<Slider.Thumb
className="SliderThumb"
aria-label="Tag Instance Threshold"
/>
</Slider.Root>
</div>
{/* CENTER STRENGTH */}
<div className="_tag-cloud-control-row flex-row-center">
<label className="mono-label mr-1">{`CENTER STRENGTH (${centerStrength})`}</label>
<Slider.Root
className="SliderRoot ml-auto"
value={[centerStrength]}
onValueChange={(value) => setCenterStrength(value[0])}
min={-600}
max={0}
step={5}>
<Slider.Track className="SliderTrack">
<Slider.Range className="SliderRange" />
</Slider.Track>
<Slider.Thumb className="SliderThumb" aria-label="Center Strength" />
</Slider.Root>
</div>
{/* LINK STRENGTH */}
<div className="_tag-cloud-control-row flex-row-center">
<label className="mono-label mr-1">{`LINK STRENGTH (${linkStrength})`}</label>
<Slider.Root
className="SliderRoot ml-auto"
value={[linkStrength]}
onValueChange={(value) => setLinkStrength(value[0])}
min={0}
max={0.5}
step={0.05}>
<Slider.Track className="SliderTrack">
<Slider.Range className="SliderRange" />
</Slider.Track>
<Slider.Thumb className="SliderThumb" aria-label="Link Strength" />
</Slider.Root>
</div>
{/* NODE RADIUS */}
<div className="_tag-cloud-control-row flex-row-center">
<label className="mono-label mr-1">{`NODE RADIUS (${nodeRadius})`}</label>
<Slider.Root
className="SliderRoot ml-auto"
value={[nodeRadius]}
onValueChange={(value) => setNodeRadius(value[0])}
min={1}
max={20}
step={1}>
<Slider.Track className="SliderTrack">
<Slider.Range className="SliderRange" />
</Slider.Track>
<Slider.Thumb className="SliderThumb" aria-label="Node Radius" />
</Slider.Root>
</div>
{/* FREQUENCY BASED NODE RADIUS */}
<div className="_tag-cloud-control-row flex-row-center">
<label
className="mono-label mr-1"
htmlFor="frequency-based-node-radius">{`FREQUENCY BASED NODE RADIUS`}</label>
<Switch.Root
className="radix-switchroot ml-auto"
id="frequency-based-node-radius"
checked={sizeBasedNodeRadius}
onCheckedChange={(checked) => setSizeBasedNodeRadius(checked)}>
<Switch.Thumb className="radix-switchthumb" />
</Switch.Root>
</div>
{/* LABELS */}
<div className="_tag-cloud-control-row flex-row-center">
<label
className="mono-label mr-1"
htmlFor="labels-visible">{`LABELS`}</label>
<Switch.Root
className="radix-switchroot ml-auto"
id="labels-visible"
checked={labels}
onCheckedChange={(checked) => setLabels(checked)}>
<Switch.Thumb className="radix-switchthumb" />
</Switch.Root>
</div>
</form>
);
};

export default TagCloudControls;

Comments

Recent Work

Free desktop AI Chat client, designed for developers and businesses. Unlocks advanced model settings only available in the API. Includes quality of life features like custom syntax highlighting.

Learn More

BidBear

bidbear.io

Bidbear is a report automation tool. It downloads Amazon Seller and Advertising reports, daily, to a private database. It then merges and formats the data into beautiful, on demand, exportable performance reports.

Learn More