I have had word clouds embedded in the tag pages of this site for a while. But - they required you to have JavaScript enabled. That’s not too fun.
As it turns out - if you install jsdom and canvas, you can use the d3-cloud’s canvas() function to inject a canvas created using JSDom. This approach works server-side without any browser APIs, and returns an SVG string. This site uses Astro - so none of these APIs are available when rendering, as would be the case if you were running this in node.js.
I also was able to adapt it so the tags were clickable using <a> tags. I also embedded a small splitmix32 as a deterministic random number generator so that the display is much more predictable between renders.
My example code below, also uses Tailwind classes - so you may need to either convert to inline styles or adapt to your site. The best part is - this works for both dark and light mode, because it can inherit the styles of the site. I run this as a function in astro, then embed it in the page, using <Fragment set:html={svg} />
const svg = renderWordCloud({ words, linkFunction: tag => `/categories/${tag}` })
...
<Fragment set:html={svg} />
Results
It looks something like this, using the IBM Plex Mono Font:

You can see the live version at /blog/tags/
Full Code
import d3Cloud from "d3-cloud";
import * as d3 from 'd3';
import { JSDOM } from 'jsdom';
import { registerFont } from 'canvas';
// register the font to use in the word cloud
registerFont('./public/fonts/IBMPlexMono-Regular.ttf', { family: 'IBM Plex Mono' });
/**
* splitmix32 PRNG based on a string seed.
* src: https://stackoverflow.com/a/47593316
* @param seedString
* @returns
*/
function splitmix32(seedString: string) {
// Convert the string seed into a numerical seed
let seed = 0;
for (let i = 0; i < seedString.length; i++) {
seed = (seed + seedString.charCodeAt(i)) | 0;
}
return function() {
seed |= 0;
seed = seed + 0x9e3779b9 | 0;
let t = seed ^ seed >>> 16;
t = Math.imul(t, 0x21f0aaad);
t = t ^ t >>> 15;
t = Math.imul(t, 0x735a2d97);
return ((t = t ^ t >>> 15) >>> 0) / 4294967296;
}
}
type WordNode = { text: string; size: number; x: number; y: number; rotate: number };
// ref: https://d3-graph-gallery.com/graph/wordcloud_size.html
export function renderWordCloud({ words, linkFunction }: { words: { word: string; size: number }[], linkFunction: (word: string) => string }) : string {
const maxSize = d3.max(words, d => d.size) || 1;
// set the dimensions and margins of the graph
var margin = { top: 10, right: 10, bottom: 10, left: 10 },
width = 720 - margin.left - margin.right,
height = 450 - margin.top - margin.bottom;
// Create a new JSDOM instance for D3-selection to use
const dom = new JSDOM('<!DOCTYPE html><body></body>');
const document = dom.window.document;
const svg = d3.select(document.body).append('svg')
.attr("class", "max-w-full w-full h-auto min-h-full cursor-pointer")
.attr("viewBox", `0 0 ${width + margin.left + margin.right} ${height + margin.top + margin.bottom}`)
.append("g")
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
// make a new cloud
var layout = d3Cloud()
.canvas(() => document.createElement('canvas')) // inject the canvas
.size([width, height])
.words(words.map(function (d) { return { text: '#' + d.word, size: d.size }; }))
.padding(5) // space between words
.font('IBM Plex Mono') // font family
.rotate(function () { return 0 })
.random(splitmix32('richinfante.com')) // random seed based on domain name, for consistent layout between renders
.fontSize(function (d) { return Math.max(d.size ?? 0, 11); }) // font size of words
.on("end", draw);
// start the layout computation
layout.start();
function draw(words: WordNode[]) {
svg
.append("g")
.attr("transform", "translate(" + layout.size()[0] / 2 + "," + layout.size()[1] / 2 + ")")
.selectAll("text")
.data(words)
.enter().append("text")
// style attrs for the svg text node
.attr("text-anchor", "middle")
.attr("class", "select-none fill-[var(--token-text)] hover:fill-[var(--token-link)] transition-all duration-300 ease-in-out hover:!opacity-100")
// NOTE: looks like JSDOM doesn't support .style with functions properly?
// .style("font-size", function (d) { console.log(d); return d.size; })
// .style("font-family", "IBM Plex Mono")
// .style('opacity', function(d) { return d.size * 5 / maxSize; })
.attr("style", function(d) { return `font-size: ${d.size}px; font-family: 'IBM Plex Mono'; opacity: ${d.size * 5 / maxSize};`; })
// apply transform based on the computed x,y,rotate
.attr("transform", function (d) {
return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
})
// NOTE - could .onclick, but to avoid any JS, I can instead inject an <a> tag.
// this onclick is a string because we can't create a real function in JSDom that'll work client side
// .attr('onclick', function (d) { return `window.location.href='/category/${d.text.substring(1)}'`; })
// NOTE: could do .text() - but I want links. We'll embed an "a" tag instead.
// .text(function (d) { return d.text; })
.append(function(d) {
const a = document.createElement('a')
a.setAttribute('href', linkFunction(d.text));
// a.setAttribute('style', 'text-decoration: none; color: inherit;');
a.setAttribute('class', 'plain select-none !text-[var(--token-text)] !hover:text-[var(--token-link)] transition-all duration-300 ease-in-out hover:!opacity-100');
a.textContent = d.text;
return a;
})
}
// pull out the html
return document.body.innerHTML;
}