npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

wordcloud-wasm

v0.3.0

Published

High-performance WASM layout engine for word clouds and general visualization layouts

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 LayoutEngine trait 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 showcase

GitHub Pages

A showcase page (index.html) with live demos and code samples is served from the repo root. To host it yourself:

  1. Build the WASM package into pkg/:
    cd crate
    wasm-pack build --target web --out-dir ../pkg
  2. Commit pkg/ (the four generated files are not git-ignored).
  3. 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-unknown target: rustup target add wasm32-unknown-unknown
  • wasm-pack

Building

cd crate
wasm-pack build --target web --out-dir ../pkg

This 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.html

SIMD 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 ../pkg

Honest 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-threads

Then 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: null to opt out deliberately, or supply your own from measureText().

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 .wasm as 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.wasm into your app's public/ folder as part of your own build step (avoid postinstall scripts — 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 path

Known 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 metrics if 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.