wordcloud-wasm
v0.3.0
Published
High-performance WASM layout engine for word clouds and general visualization layouts
Maintainers
Readme
wordcloud-wasm
A Rust + WebAssembly layout engine that places word clouds and other visualizations off the main thread, scaling from 50 to 50 000+ words. Designed as a generic geometry engine, not a single-purpose tool.
The engine only computes geometry — where each word goes. Your code does the drawing, so it works with any renderer: Canvas, SVG, D3, React, dashboards.
See the complete API.md reference for package exports, functions, types, and usage examples.
Why it exists
The canonical JS word-cloud library (d3-cloud) is single-threaded, struggles
past ~1000 words, and hasn't had a major release in years. This project takes a
different approach:
- Off the main thread — layout runs in a Web Worker; your UI never freezes.
- Bitmap collision — all placed words collapse into one
Vec<u64>occupancy bitmap, so collision cost is O(mask area), independent of how many words are already placed. - Three placement strategies — spiral search for aesthetics (50–500 words), shelf/row packing for scale (5 000–50 000+ words), and an optional spatial-grid mode in between. The spiral walks an Archimedean curve for a round cloud or square rings for a box-filled one, and can mix upright and sideways words to fit more of them in.
- Generic layout framework — a
LayoutEnginetrait means treemap, circle packing, label placement, and bubble charts can all share the same collision and rasterization infrastructure. Treemap is already implemented.
Benchmarks
Measured on the host CPU (scalar kernels, release build), same deterministic
datasets for both engines. Full methodology in BENCHMARKS.md.
vs. d3-cloud (single-threaded, same inputs)
| Words | Fast mode | Pretty mode | d3-cloud | Fast vs d3-cloud | |-------|-------------|-------------|----------|------------------| | 100 | 0.97 ms | 11 ms | 112 ms | ~120× | | 500 | 2.6 ms | 173 ms | 384 ms | ~150× | | 5 000 | 14 ms | 9 858 ms | 2 551 ms | ~180× |
Reading it honestly:
- Fast mode beats d3-cloud at every scale — including at 5 000 words where Pretty's O(n²) spiral falls behind.
- Pretty is faster than d3-cloud at small N (11 ms vs 112 ms at 100 words) and is the better-looking mode for small clouds.
- d3-cloud rasterizes every word to a canvas and reads the pixels back; this engine only measures each word once and packs advance-width boxes. Part of d3-cloud's cost is work this engine deliberately skips.
Fast mode scaling
| Words | Time | Placed | |--------|--------|---------------| | 500 | 1.6 ms | 500 / 500 | | 1 000 | 3.1 ms | 1 000 / 1 000 | | 5 000 | 12 ms | 5 000 / 5 000 | | 50 000 | 88 ms | 50 000 / 50 000 |
100% placement at every scale. The trade is appearance — a justified grid, not an organic spiral cloud.
Project layout
crate/ Rust source (wordcloud_layout cdylib + rlib)
pkg/ Generated wasm-pack output (committed for GitHub Pages; rebuild with wasm-pack)
js/ JS/TS wrapper around pkg/
examples/ Browser usage examples
scripts/ Dev tooling (SIMD/scalar timing script)
index.html GitHub Pages showcaseGitHub Pages
A showcase page (index.html) with live demos and code samples is served from the
repo root. To host it yourself:
- Build the WASM package into
pkg/:cd crate wasm-pack build --target web --out-dir ../pkg - Commit
pkg/(the four generated files are not git-ignored). - Push to GitHub, then in Settings → Pages set source to "Deploy from branch",
branch
main, folder/ (root).
The live site is at: https://rugsaw.github.io/wordcloud-wasm/
Prerequisites
- Rust toolchain (
rustc/cargo) wasm32-unknown-unknowntarget:rustup target add wasm32-unknown-unknownwasm-pack
Building
cd crate
wasm-pack build --target web --out-dir ../pkgThis emits pkg/wordcloud_layout.js, pkg/wordcloud_layout_bg.wasm, and
TypeScript definitions. Then serve the project root over HTTP — browsers block
WASM/ES modules over file://:
python -m http.server # open http://localhost:8000/examples/canvas.htmlSIMD build (optional)
A v128 kernel exists for the bitmap AND-scan / OR-write hot path:
cd crate
RUSTFLAGS="-C target-feature=+simd128" wasm-pack build --target web --out-dir ../pkgHonest note: SIMD currently gives no measurable end-to-end speedup (≈1×,
within noise). The spiral search is the bottleneck for Pretty/Balanced, not the
bitmap kernel. The v128 code is correct and tested, but the leverage point
is the spiral candidate count, not vectorized memory scans. SIMD would help in a
kernel-bound loop with wide masks; for now, spiral step-scaling (~25× win) moved
the needle.
Multi-threaded build (optional, experimental)
Mask rasterization is independent per word and fans out across threads with Rayon. Measured speedup:
| Words (Fast) | Single-threaded | Parallel | Speedup | |--------------|-----------------|----------|---------| | 5 000 | 12 ms | 5.8 ms | ~2× | | 50 000 | 88 ms | 46 ms | ~1.9× |
Not published to npm. The default wordcloud-wasm package ships only the
single-threaded pkg/, and js/threads.js is not one of its exports — this
is a build-from-source workflow only.
The threaded build requires a nightly toolchain and a cross-origin-isolated
page (Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy:
require-corp):
cd crate
rustup toolchain install nightly
rustup component add rust-src --toolchain nightly
RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" \
CARGO_UNSTABLE_BUILD_STD="panic_abort,std" \
rustup run nightly \
wasm-pack build --target web --out-dir ../pkg-threads --features wasm-threadsThen initialize the thread pool before the first layout call:
import { initThreads } from "./js/threads.js";
import { layout } from "./js/index.js";
const n = await initThreads(); // 0 → single-threaded fallback
const placements = await layout(items, { mode: "fast", width, height });Falls back cleanly to single-threaded if the page isn't isolated or
SharedArrayBuffer is unavailable.
Usage
Word cloud layout
import { layout } from "./js/index.js";
const items = [
{ text: "WebAssembly", weight: 100 },
{ text: "Rust", weight: 80 },
{ text: "layout", weight: 40 },
];
const placements = await layout(items, {
mode: "pretty", // "pretty" | "balanced" | "fast"
width: 960,
height: 600,
minFontSize: 14,
maxFontSize: 88,
padding: 2,
});
// → [{ text, x, y, rotation, fontSize, baselineOffset }, ...]
// x/y are the bounding-box center — render with center anchoring.Render to a <canvas>:
const ctx = canvas.getContext("2d");
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (const p of placements) {
ctx.save();
ctx.translate(p.x, p.y);
if (p.rotation) ctx.rotate((p.rotation * Math.PI) / 180);
ctx.font = `${p.fontSize}px sans-serif`;
ctx.fillText(p.text, 0, 0);
ctx.restore();
}A full working page is in examples/canvas.html.
Matching the layout to your font
Tell layout which font family you render with and it will size words to match:
const placements = await layout(items, {
width: 960,
height: 600,
font: "Inter, sans-serif", // defaults to "sans-serif"
fontWeight: 600, // optional
fontStyle: "italic", // optional
});
for (const p of placements) {
ctx.font = `600 italic ${p.fontSize}px Inter, sans-serif`;
ctx.fillText(p.text, p.x, p.y);
}No font file is involved. The wrapper measures each word with a canvas
measureText in exactly the font string you will draw with, which means kerning,
ligatures, font fallback and shaping are all accounted for — the browser is
measuring its own work. Measurements are fractions of the font size, so one pass
covers every size the layout assigns, and they are cached per font so repeated
layouts over the same words cost nothing.
This matters because the engine itself has no font. Left to its built-in approximation of a generic sans-serif, it under-measures anything wider than that guess, and the drawn glyphs overrun the space reserved for them. Placements are center-anchored, so the excess splits across both ends of the word and shows up as a slight overlap on its first and last letters.
Placements also carry baselineOffset, the distance from y down to the text
baseline. Drawing with textBaseline: "alphabetic" at y + p.baselineOffset
positions text exactly, rather than depending on how a given browser defines
"middle":
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillText(p.text, p.x, p.y + p.baselineOffset);Two cases need more than the font option:
- Web Workers. A worker's font set is not the document's, so a font the page
loaded at runtime would measure there as a fallback face.
createLayoutWorker()handles this by measuring on the main thread before posting. - No canvas at all (plain Node, a locked-down worker). Measurement is skipped
and the built-in approximation applies. Pass
metrics: nullto opt out deliberately, or supply your own frommeasureText().
The live demo has a font selector and a toggle for measurement, so you can see the difference on a real cloud.
Choosing a mode
| Mode | Words | Look | Notes |
|------|-------|------|-------|
| pretty (default) | 50–500 | Organic, spiral | Recommended default. Faster than d3-cloud at small N. |
| balanced | 500–5 000 | Same as pretty | Adds a spatial-grid broad phase that doesn't beat the bitmap — ~2× slower than pretty. Kept for completeness. |
| fast | 5 000–50 000+ | Grid-packed | Bounded linear time, 100% placement. Trade: less organic appearance. |
Prefer pretty for ≤ 500 words and fast for everything larger. Skip
balanced unless you have a specific reason.
Choosing a shape
Mode picks the packing strategy; spiral picks the path pretty and
balanced search along, and with it the cloud's silhouette:
await layout(items, { mode: "pretty", spiral: "rectangular", width, height });| spiral | Path | Look |
|----------|------|------|
| "archimedean" (default) | r = b·θ | Round cloud, empty canvas corners. |
| "rectangular" | Growing square rings | Box-filled cloud; words reach the corners. d3-cloud's other path. |
Both start at the exact canvas center, so the heaviest word doesn't move, and
both cost the same to search. fast never searches, so it ignores the option.
Because the square rings grow by equal steps on both axes, a wide canvas can be
left with empty bands at the left and right. See
examples/spiral.html for a side-by-side comparison.
Mixing in sideways words
rotate is the other search option. By default every word is horizontal; set
"mixed90" and pretty/balanced will turn words a quarter turn wherever
that fits better:
await layout(items, { mode: "pretty", rotate: "mixed90", width, height });| rotate | Orientation | Effect |
|----------|-------------|--------|
| "none" (default) | All words horizontal | Identical to every earlier release. |
| "mixed90" | A mix of rotation: 0 and rotation: 90 | Fits more words — a long word can drop into a tall, narrow gap. |
Each word is rasterized in both orientations and the search tries the preferred
one first, alternating down the weight-sorted list: upright at even positions, a
quarter turn at odd ones. It falls back to the other orientation only when the
first finds no gap, and is skipped when neither does. That preference is a pure
function of the input, so the layout stays deterministic — unlike d3-cloud, no
angle is drawn at random. The price is two masks per word and a second spiral
walk for words that miss, so "mixed90" is slower than "none". fast ignores
it: its rows are height bands, which only read as a block when every word runs
the same direction.
A rotation: 90 placement reports the rotated bounding box, so the canvas
snippet above already draws it correctly — x/y is the point to rotate about.
Note that baselineOffset describes upright text, so use
textBaseline: "middle" for rotated words rather than the alphabetic path.
See examples/rotate.html for a side-by-side
comparison and the exact draw code.
Running off the main thread (Web Worker)
import { createLayoutWorker } from "./js/layout-worker.js";
const layoutWorker = createLayoutWorker(); // spawns js/worker.js as a module worker
const placements = await layoutWorker.layout(items, { mode: "pretty", width, height });
// render exactly as above
layoutWorker.terminate();The worker loads WASM once and reuses it across calls. Concurrent calls are
safe — each gets its own request ID. A working demo (with a spinner that keeps
animating to prove the main thread stays responsive) is in
examples/worker.html.
Treemap
The engine isn't word-cloud-only. treemap() tiles the canvas into rectangles
whose areas are proportional to weight (squarified algorithm):
import { treemap } from "./js/index.js";
const rects = await treemap(
[{ text: "Rust", weight: 78 }, { text: "wasm", weight: 40 }],
{ width: 960, height: 600 },
);
// → [{ text, value, x, y, width, height }, ...] — x/y are top-left.A working page is in examples/treemap.html.
Using with a bundler (Next.js, Vite, Webpack, etc.)
ready() / layout() / treemap() auto-initialize the WASM module by
fetching wordcloud_layout_bg.wasm relative to the generated JS file
(new URL(..., import.meta.url) inside pkg/wordcloud_layout.js). Whether
that "just works" depends on your bundler correctly recognizing .wasm as a
static asset — which often isn't the case out of the box (Next.js, notably,
needs extra config), and is especially fragile with static exports
(output: "export"), since there's no server to fall back on if the asset
path is wrong.
Recommended: load the .wasm from a CDN — zero bundler config needed.
ready() accepts any URL, so you can point it straight at the published
package on a CDN like unpkg instead of relying on the
bundler to resolve the local file at all:
import { ready, layout } from "wordcloud-wasm";
import { version } from "wordcloud-wasm/package.json";
await ready(`https://unpkg.com/wordcloud-wasm@${version}/pkg/wordcloud_layout_bg.wasm`);
const placements = await layout(items, { mode: "pretty", width, height });Deriving the URL from wordcloud-wasm/package.json keeps the fetched .wasm
locked to whatever version of the JS bindings you actually have installed —
always pin the version (never fetch @latest). The JS glue code and the
.wasm binary come from the same wasm-bindgen build and aren't guaranteed
ABI-compatible across versions, so a drifted CDN version can fail in
confusing ways.
This needs network access at runtime and an unpkg-friendly CSP
(connect-src https://unpkg.com), but requires no bundler configuration and
no manual file copying — it works identically in dev, in a normal build, and
in a static export.
Self-hosting instead (offline / CSP-restricted environments):
Webpack (Next.js, CRA, etc.) — treat
.wasmas a static asset so the bundler copies the file and rewrites the URL for you automatically:// next.config.js module.exports = { webpack(config) { config.module.rules.push({ test: /\.wasm$/, type: "asset/resource", generator: { filename: "static/wasm/[name].[hash][ext]" }, }); return config; }, turbopack: { rules: { "*.wasm": { type: "asset" } }, }, };Manual copy — copy
pkg/wordcloud_layout_bg.wasminto your app'spublic/folder as part of your own build step (avoidpostinstallscripts — package managers increasingly restrict them for security), then pass the static path explicitly:await ready("/wasm/wordcloud_layout_bg.wasm");
Architecture
The engine is a generic framework, not a single-purpose word-cloud. A layout
strategy implements the LayoutEngine trait:
pub trait LayoutEngine {
type Item;
type Config;
type Output;
fn layout(&self, items: &[Self::Item], config: &Self::Config) -> Vec<Self::Output>;
}The three word-cloud modes are strategies over Item → Placement
(Pretty, Balanced, Fast).
Treemap is a fully-implemented strategy with its own
output type, proving the framework generalizes beyond word clouds.
The expensive infrastructure — occupancy bitmap, spatial grid, mask
rasterization, SIMD kernels — lives in standalone modules (bitmap, grid,
mask, raster, simd) that any strategy can reuse.
Adding a new layout: circle packing, bubble, and label placement are
scaffolded in crate/src/scaffolds.rs — each is a
real impl LayoutEngine (returning empty for now) with design notes on its
algorithm. Filling one in touches no existing strategy and no shared module.
Running the tests
cd crate
cargo test # unit + integration tests
cargo test --features parallel # also tests the parallel rasterization pathKnown limitations
Footprints are boxes, not glyph outlines. A word reserves a rectangle per character rather than the exact shape of its ink, so words cannot nest into each other's ascender and descender gaps. This is deliberate: contour-accurate masks pack far denser, and the whitespace a box carries is much of what keeps a cloud legible. The box is sized from a real measurement of your font (see Matching the layout to your font), so it is the right size even though it is not the right shape.
Measurement needs a canvas. Outside a browser — plain Node, say — there is nothing to measure with, and the engine falls back to its built-in approximation of a generic sans-serif. Supply your own measurements through
metricsif you have them.Rotation is 0° or 90° only.
rotate: "mixed90"mixes upright and sideways words; arbitrary angles are not supported, because a mask is an axis-aligned bitmap. Fast mode stays upright either way.Fast mode may skip words when no gap exists in any row. In practice this doesn't happen until the canvas is extremely dense — all 50 000 words are placed in the benchmarks above.
Verifying the WASM build
import init, { ping, version } from "./pkg/wordcloud_layout.js";
await init();
console.log(ping()); // "wordcloud_layout v0.3.0 ready"
console.log(version()); // "0.3.0"Contributing
Contributions are welcome! See CONTRIBUTING.md for build
setup, testing, and the (recommended) commit message style.
