Word clouds are a classic way to show weighted text — but packing hundreds of words
without overlap is surprisingly hard. This example walks through the full pipeline: you hand
the engine a list of weighted terms, it returns positions in WebAssembly, and you paint
them on a plain <canvas>. No bundler, no framework — just ES modules
over HTTP.
x, y, rotation, fontSize). Your code
decides how to draw it — colors, fonts, hover states, SVG, WebGL, or DOM. That
separation is intentional: WASM does the expensive packing; you keep full control of the UI.
What happens on each call to layout():
weight is mapped into
the [minFontSize, maxFontSize] range. Heavier words get larger glyphs.font, so the engine knows its real bounding box.
Input is always an array of { text, weight }. Weights are relative — only
their ratio matters. Sorting is optional; the engine re-orders by weight internally
(largest first) so big words claim space before small ones.
/** @type {Array<{ text: string, weight: number }>} */ export const items = [ { text: "WebAssembly", weight: 100 }, { text: "Rust", weight: 80 }, { text: "layout", weight: 40 }, { text: "spiral", weight: 25 }, { text: "bitmap", weight: 18 }, ]; // Weights need not sum to 100 — { weight: 1 } and { weight: 1000 } behave the same // as { weight: 0.001 } and { weight: 1 } after normalization.
layout() with canvas dimensions
Pull in layout from the package entry point. The first call downloads and
instantiates the WASM module; later calls reuse the cached instance. Pass the pixel size
of the area you will draw into — typically canvas.width and
canvas.height after you set them.
import { layout } from "./js/index.js"; import { items } from "./data.js"; const canvas = document.getElementById("cloud"); const width = 960; const height = 600; // Set backing-store size before layout — layout uses these exact pixels. canvas.width = width; canvas.height = height; const placements = await layout(items, { mode: "pretty", // placement quality (see table below) width, // layout bounds — match canvas height, minFontSize: 14, // smallest word in px maxFontSize: 88, // largest word in px padding: 2, // extra gap between word boxes (px) font: "system-ui, sans-serif", // the family you render with }); // placements → [{ text, x, y, rotation, fontSize, baselineOffset }, ...] // Not every input word is guaranteed to fit — check placements.length. console.log(`placed ${placements.length} of ${items.length} words`);
Layout modes trade placement quality for speed:
| Mode | Behavior | When to use |
|---|---|---|
"pretty" |
Thorough spiral search. Tightest, most organic clouds. | Final visuals, < 2 000 words, screenshots. |
"balanced" |
Middle ground between search depth and throughput. | Interactive dashboards that re-layout on filter changes. |
"fast" |
Row packing instead of a search. Places more words per millisecond. | 5 000+ words, live previews, repeated re-layouts. |
Each placement's x and y are the center of the
word's bounding box — not the baseline and not the top-left corner. Set
textAlign: "center" and textBaseline: "middle", then draw at
(0, 0) after translating to (p.x, p.y). Rotation is in
degrees — always 0 unless you pass
rotate: "mixed90", which also emits 90; convert to radians for
ctx.rotate().
/** * Draw all placements. Call after layout() resolves. * @param {CanvasRenderingContext2D} ctx * @param {Array} placements — from layout() * @param {string[]} [palette] — optional per-word fill colors */ export function drawWordCloud(ctx, placements, palette = ["steelblue"]) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); ctx.textAlign = "center"; ctx.textBaseline = "middle"; for (let i = 0; i < placements.length; i++) { const p = placements[i]; ctx.save(); // isolate transform + style per word ctx.translate(p.x, p.y); // move origin to bbox center if (p.rotation) { ctx.rotate((p.rotation * Math.PI) / 180); } ctx.font = `${p.fontSize}px system-ui, sans-serif`; ctx.fillStyle = palette[i % palette.length]; ctx.fillText(p.text, 0, 0); // (0,0) = center because of align/baseline ctx.restore(); } }
options.font and it will be measured for you. Skip that and
words are sized against a built-in guess at a generic sans-serif — the further your real
typeface or weight is from that guess, the more the drawn glyphs overrun their reserved
space, which reads as slight overlap at a word's first and last letters.
A minimal page loads the module, sizes the canvas to its container, runs layout once, and
draws. Re-call layout() whenever data or dimensions change (resize, filter,
theme toggle).
import { layout } from "./js/index.js"; import { items } from "./data.js"; import { drawWordCloud } from "./render.js"; const canvas = document.getElementById("cloud"); const ctx = canvas.getContext("2d"); async function renderCloud() { const w = Math.floor(canvas.getBoundingClientRect().width) || 800; const h = Math.round(w * 0.62); canvas.width = w; canvas.height = h; const placements = await layout(items, { mode: "pretty", width: w, height: h, minFontSize: 14, maxFontSize: 72, padding: 2, }); drawWordCloud(ctx, placements, [ "#1f6feb", "#238636", "#a371f7", "#db6d28", ]); } renderCloud(); window.addEventListener("resize", () => { renderCloud(); });
Serve the project over HTTP (npx serve . works well) so
import and the .wasm file load correctly.
Use the controls below to run the pipeline on random datasets. The status line shows how many words fit, which layout mode ran, and how long packing took.
Presets tune minFontSize / maxFontSize so words stay readable
at each scale (50 → 16–88px, 500 → 10–52px, 5 000 → 6–16px + fast mode).
Fullscreen lays out against the viewport so you can see the engine's maximum placement count.