Charts

Word Cloud

A four-state word cloud that sizes terms by the square root of their count and packs them along a deterministic spiral, with every word a focusable button and a ranked list for screen readers.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Type } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  prepareWordCloudTerms,
  type ChartWordCloudData,
  type WordCloudRankedTerm,
  type WordCloudScale,
} from "./chart-word-cloud.contract"

export interface ChartWordCloudProps

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-word-cloud.json

Prompt

Build a React + TypeScript + Tailwind "ChartWordCloud" card in plain SVG with
zod. No charting library: a word cloud is a text-packing problem, and no
recharts primitive lays overlapping boxes out along a spiral.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready";
    title: string; unit?: string;
    terms: { id: string; text: string; value: number >= 0; group?: string }[] }
  Component props = z.infer of that, plus description, scale ("sqrt" |
  "linear" | "rank", default "sqrt"), maxTerms (60, clamped 1-200),
  minFontSize (12, clamped 6-28), maxFontSize (42, clamped 16-96 and forced to
  at least the floor + 2), padding (5, clamped 0-16), spiralPitch (7, clamped
  2-24), ungroupedLabel, locale ("en-US"), formatValue, onSelect, onRetry,
  className, and the remaining native div props spread on the root.
- NEGATIVE COUNTS ARE REFUSED at parse time, not mapped. A term cannot occur
  -5 times, and the two things people reach for a negative frequency to mean
  (sentiment, delta against a baseline) are a SECOND dimension. Carry that in
  `group` and let colour say it, so "bigger" never has to be read as "more
  negative".
- Ship a pure preparation function beside the schema:
  prepareWordCloudTerms(terms, { scale, maxTerms, ungroupedLabel }) returning
  { ranked, stats }. It dedupes by id (first wins), drops blank text and
  negative or non-finite counts, sorts by count descending with ties broken by
  text and then id using PLAIN CODE-UNIT COMPARISON — localeCompare would
  order equal counts differently per visitor, and here the sort order IS the
  layout order, so two visitors would be handed two different pictures. Then
  it cuts to maxTerms and emits { rank, share, weight } per term and
  { total, kept, invalid, duplicates, overflow, min, max, uniform, groups }
  for the set.

Behavior
- SIZE. weight is 0..1 and the renderer maps it with
  fontSize = minFontSize + weight * (maxFontSize - minFontSize).
    sqrt    weight = sqrt(value / max)     <- default
    linear  weight = value / max
    rank    weight = 1 - (rank - 1) / (kept - 1)
  Take the square root because font size is a LINEAR measure while the ink a
  word lays down grows with its SQUARE: the advance width and the cap height
  both scale with the size, so area goes as size^2. Only under sqrt does
  "twice the area" mean "twice as often". linear makes a term mentioned twice
  as often look four times as loud. rank throws magnitude away entirely and is
  the rescue for a set where one term is 100x the rest — under sqrt that whole
  tail lands on the font floor and stops ranking itself.
- The floor is an ADDITIVE offset, which is also what breaks the area
  proportion: at minFontSize 0 the areas are exactly proportional and the tail
  is unreadable; at 12 the tail reads and a term with a hundredth of the top
  count is painted rather more than a hundredth of the area. Same trade as a
  minimum bar width — legibility bought with accuracy. Say so in the footer
  and keep the exact number in the readout and the ranked list.
- DEGENERATE SIZES, both deliberate. Exactly one kept term gets weight 1: it
  is trivially its own maximum, and it is also the single-category-at-100%
  case. Two or more terms with identical counts all get weight 0.5, and the
  footer says out loud that size carries no information here. Do not let the
  zero-anchored formula hand every term the maximum in that case — forty words
  at 42px fit in no box.
- MEASURING TEXT. Advance widths decide the packing and they are not available
  during SSR, so do both, in this order. (1) Estimate from character classes:
  CJK and full-width one em, space 0.28, M W m w 0.85, I J f i j l r t and
  punctuation 0.31, A-Z 0.66, digits 0.57, everything else 0.53. It runs
  identically on the server and in the browser, so the markup hydrates without
  a mismatch. Treating every character as one average advance is off by about
  30% on an all-caps run and by 100% on a Chinese phrase, which is the
  difference between a tidy cloud and one with words sitting on top of each
  other. (2) After mount, read the real numbers: render one hidden <text> per
  DISTINCT string inside the same <svg> at a fixed reference size — use
  visibility:hidden, never display:none, which removes the element from the
  render tree and makes its text length zero — call getComputedTextLength(),
  divide by the reference size and keep the ratio. The ratio is
  size-independent, so one measurement serves that word at any font size. The
  measuring layer must carry the SAME font-affecting classes as the painted
  words — semibold is wider than regular, so measuring at weight 400 and
  drawing at 600 under-reserves every single box and the cloud overlaps.
  Re-read once on document.fonts.ready, because a fallback face and the real
  face have different metrics and a layout computed from the fallback leaves
  visible gaps. Compare against the previous map before committing, or the
  effect's own setState re-renders forever.
- OVERFLOW, in two steps. A word wider than the plot first shrinks until it
  fits, but never below minFontSize. If it is still too wide at the floor,
  clip it and append an ellipsis, budgeting the ellipsis first and using the
  string's average per-character advance for the prefix — an approximation,
  rounded down on purpose. The untouched spelling stays in the accessible name
  and in the ranked list. Budget the packer's padding out of the plot width
  before either step, or a word that exactly fills the plot ends up one
  padding too wide and is reported as unplaceable.
- LAYOUT is a greedy spiral pack and it is entirely deterministic: no
  randomness, no clock, no rotation. Words are consumed biggest first, so the
  most frequent term lands at the exact centre and "closer to the middle"
  becomes a real second encoding of rank instead of an accident. For each
  word, walk an Archimedean spiral r = a * theta with a = spiralPitch / 2pi,
  stretched on x by the plot's aspect ratio so a wide box is swept as evenly
  as a square one, and take the first sample where the word's box, inflated by
  padding, lies inside the plot and overlaps nothing already placed. The
  overlap test is axis-aligned: x0 < ox1 && x1 > ox0 && y0 < oy1 && y1 > oy0.
- STEP THE SPIRAL BY ARC LENGTH, NOT BY ANGLE. An Archimedean spiral's arc
  length is about (a/2) * theta^2, so theta = sqrt(2s/a) advances a constant
  distance per step. A constant ANGULAR step samples the outer turns tens of
  times more sparsely than the inner ones — exactly where the small words are
  still hunting for a gap — so they get reported as "did not fit" while
  sitting next to plenty of free space.
- STOPPING. A sample is inside the plot iff |r * aspect * cos| <= limitX and
  |r * sin| <= limitY; divide the first by aspect and the largest radius any
  angle can still satisfy is hypot(limitX / aspect, limitY). Break there.
  Inside that radius, skip an out-of-bounds sample with continue and NEVER
  break: the spiral leaves and re-enters the plot on every turn, and the gap
  this word needs may be on the way back in. Refuse a box bigger than the plot
  up front rather than spending 3,000 steps discovering it. Cost is
  O(words x steps x placed) in the worst case, so memoise the whole scene on
  the data, the font range, the padding and the pitch — it must run once per
  data change, never once per hover.
- A term that finds no spot is NOT dropped: count it, name the first few in
  the footer, and mark it in the ranked list. Same for terms cut by maxTerms,
  duplicated ids and refused rows — everything the picture omits is stated
  somewhere a reader can find it.
- The four states are first-class branches of one bg-card panel: a pulsing
  skeleton of word-shaped rects (aria-hidden, fixed coordinates, no
  randomness) plus one sr-only role="status" line; an empty state; an error
  state whose "Try again" button appears only when onRetry exists; and ready.
  Ready with nothing left to rank is a FIFTH branch with its own sentence —
  "the extraction ran and matched nothing" is not "the extraction has not run".

Rendering & styling
- Semantic tokens only: bg-card, text-card-foreground, border,
  text-muted-foreground, text-destructive, bg-muted / fill-muted, stroke-card,
  stroke-foreground, ring, and var(--chart-1..5) for the terms. cn() merges
  the consumer's className into the root; the component forwards its ref and
  spreads the rest of the native div props.
- COLOUR IS NEVER THE ONLY CHANNEL. When at least one term carries a group,
  colour means the bucket AND each bucket also gets its own underline dash
  (solid, dashed, dotted, dash-dot, long-dash) drawn under the word at its
  exact advance width — a pattern survives greyscale, colour blindness and a
  bad projector in a way five hues do not. When nothing is grouped, colour is
  the rank quintile, which size already says, so it is redundant by
  construction and a colour-blind reader loses nothing.
- INK SCALES WITH SIZE. The five --chart-* tokens are tuned to clear 3:1
  against the card in both themes, which is the bar for large text (24px and
  up) and for non-text marks — but a cloud's tail is 12px text, where the bar
  is 4.5:1, and --chart-1 computes to about 3.6:1 on the light card. So mix
  the token toward --foreground as the word gets smaller: the full token at
  24px and above, falling to 70% at the floor, where --chart-1 computes to
  roughly 6.6:1 light and 7.5:1 dark. Mixing toward --foreground always
  travels away from the surface — darker under the light theme, lighter under
  the dark one — so one formula fixes both themes. Never mix toward --card:
  that walks the ink into the surface it has to be read against.
- RESPONSIVE without measuring the container: one fixed viewBox, width 100%,
  height auto, an explicit CSS aspect-ratio so a flex parent cannot collapse
  it to zero, and preserveAspectRatio="xMidYMid meet". Every geometric number
  is in viewBox units, so the whole cloud scales as one piece; below the
  nominal width the text scales down with it, which is the honest trade for
  keeping the layout intact, and the ranked list is what carries the data on a
  phone.
- ACCESSIBILITY, which is the part a canvas word cloud cannot do at all:
  * every word is a <text role="button"> under a roving tab stop, with an
    aria-label carrying text, count, share, rank and group, so the picture is
    operable and no number is trapped in pixels;
  * the <svg> is role="group", NOT role="img" — img is
    children-presentational and would hide every focusable word inside it from
    the very readers those labels are written for;
  * aria-describedby points at an sr-only paragraph stating the actual
    finding: how many terms, the total, the leader with its count and share,
    what the top five add up to, which scale is in force, the buckets, and how
    many terms are missing from the picture;
  * an sr-only ordered list repeats every kept term with its exact count and
    share and marks the ones that were not drawn. Put sr-only on the WRAPPER;
    if you swap the list for a table, keep it on the wrapper, because CSS
    width is only a lower bound for a table box, so width:1px does not hold
    one back and a narrow viewport picks up hundreds of px of horizontal
    scroll;
  * the visible readout line under the cloud is aria-hidden on purpose: the
    focused word already announces all of it, and a live region would say
    every word of it twice.
- KEYBOARD. One tab stop into the cloud. ArrowRight / ArrowDown next term,
  ArrowLeft / ArrowUp previous, Home / End first / last, Enter or Space fires
  onSelect. The arrows walk the RANK order, not the geometry: a cloud's
  spatial arrangement carries no meaning, so navigating it spatially would be
  navigating noise. preventDefault only after a key you actually handled, or
  the chart eats the page's own scrolling. Focus and hover share one
  indicator, drawn last so a neighbour cannot bury it: two stacked strokes,
  --card under --foreground, so it stays visible over any ink in either theme.
- Guard onSelect with event.detail > 1 rather than a timer — it reads the
  click count straight off the event, so a double click fires once and there
  is nothing to schedule or clean up. The only asynchronous work in the whole
  component is the font measurement, and its effect cancels with a flag on
  unmount and on any change to the term list.
- Motion is decoration here: the skeleton pulses with
  motion-reduce:animate-none and every transition carries
  motion-reduce:transition-none. Nothing about reading, focusing or picking a
  word depends on motion.

Customization levers
- scale: "sqrt" for anything that will be read as a measurement, "rank" when
  one term swamps the set or when the tail's own order is the story, "linear"
  only for a deliberately dramatic headline number.
- minFontSize / maxFontSize: the legibility-versus-honesty dial. Lower the
  floor toward 0 for a strictly area-proportional cloud; raise it for a
  headline card with a handful of terms; narrow the range and the cloud reads
  as a tag list rather than as a ranking.
- maxTerms: how much tail is worth drawing. Past roughly 120 terms a cloud is
  texture rather than information — cut earlier and let the ranked list carry
  the rest.
- padding and spiralPitch: the density dial. A small padding with a small
  pitch packs tightly and costs more collision tests; larger values give an
  airier cloud that holds fewer words.
- Palette: re-point the five --chart-* slots and the words and the legend
  follow together; change the 70% ink floor to trade hue for contrast; drop
  the group colouring and keep only the dashes for a monochrome brand.
- Grouping: any second dimension can ride on `group` — sentiment, source,
  language, time bucket — and the legend and the dashes come along free.
- Interaction: onSelect is the drill-in hook (filter a table, open the
  matching tickets). A hovered-group dim, or a legend that filters, keys off
  the term's group without touching the layout at all.

Concepts

  • Square-root sizing — font size is a linear measure but a word's ink grows with its square, so size ∝ √count is what makes painted area track frequency. Under a linear scale a term said twice as often looks four times as loud; the sqrt is not a smoothing trick, it is the correction that makes the picture mean what it looks like.
  • Deterministic spiral placement — words are packed biggest-first along an Archimedean spiral with an axis-aligned overlap test, with no random seed and no clock anywhere in the path. The same counts always produce the same picture, which is what makes two screenshots of two weeks actually comparable.
  • Arc-length stepping — the spiral advances a constant distance per sample, not a constant angle. Equal angles sample the outer turns far more sparsely than the inner ones, so the small words that live out there get reported as "did not fit" while sitting beside free space.
  • Measure, then re-layout — the first paint packs from per-character-class advance estimates, which are identical on the server and in the browser and so hydrate cleanly; a hidden layer then measures the real glyphs once, and once again after webfonts land, and the layout is recomputed from the true numbers.
  • Nothing vanishes quietly — terms cut by maxTerms, terms that found no free space, duplicated ids, blank strings and refused negative counts are each counted, and each is either named in the footer or marked in the ranked list. A cloud that silently drops its tail is the failure mode this one is built to avoid.
  • Ranked list as the text alternative — a cloud is unreadable to a screen reader by construction, so the real deliverable is the sr-only ordered list plus a summary that states the finding, backed by per-word buttons that announce count, share and rank. The colours are backed up by underline dashes for the same reason: nobody should need one channel to read this.

On This Page