Server-Side Rendered Word Clouds using D3.js

How I render the word clouds for this site using d3.js, d3-cloud, JSDom, and canvas.
5 min read

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: word cloud

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;
}

Feedback

Found a typo or technical problem? report an issue!

Subscribe to my Newsletter

Like this post? Subscribe to get notified for future posts like this.