Recipesemiotic/ordinalsemiotic/recipes
A classic word cloud packs words into free space: only size means anything, and position is decorative. Word Trails is a word cloud with honest axes. Every word keeps its place in a coordinate space:
- Columns are a category — here, each voice in a debate (the two candidates and the moderator).
- Vertical position is an ordered value — the segment of the debate where the word peaked, so height reads as when it was said.
- Font size encodes frequency, and words never overlap — they settle into the nearest free space around their moment in time.
- Color encodes distinctiveness: each word's usage is split across the three voices and mixed from three primaries — blue (Democrat), red (Republican), yellow (moderator). A word one camp owns is pure-hued; a word everyone uses turns purple or muddy brown. “nuclear,” for instance, reads purple because both candidates said it.
Read a column top-to-bottom to follow one speaker through the debate; read across a row to compare what everyone emphasized at the same moment. The example below runs onreal transcripts from three presidential debates with different candidate mixes. It's built on OrdinalCustomChart plus thewordTrailsLayout recipe, so it inherits real axes, keyboard navigation, tooltips, and the accessible data table for free.
Try it
Switch debates, hover any word for its speaker and segment, and pushWords / speaker to the max — or turn on Repeat words to let a word trail down its column once per segment it appeared in. However crowded it gets, nothing overlaps.
DemocratRepublicanModeratormixed hues = shared language (purple = both candidates, brown-grey = everyone)
Drop Padding toward 0 for a crowded cloud, or push Words / speaker to the max — it stays overlap-free, shrinking uniformly so relative sizes never change. Real transcripts: 2012 & 2016 via debates.org (Commission on Presidential Debates), 2020 via the m-arg dataset.
How it works
Each column is placed independently with a greedy, largest-first search: every word looks outward from its segment anchor for the nearest spot that clears every word already placed, so the layout is overlap-free by construction rather than by relaxation. Because that works on label boxes — not rasterized sprites — no canvas text measurement is needed, and the layout is pure and renders identically on the server.
Scale to fit. Before placing, the recipe computes one global font scale from the total word area, then shrinks it further only if some word cannot find room. The scale is shared by every word, so relative magnitude is preserved everywhere: add more words (or turn on repeats) and the whole cloud gets smaller together rather than any word being clipped or dropped.
Key configuration
| Prop | Purpose |
|---|
textAccessor | The word text. |
weightAccessor | Frequency → font size (sqrt-scaled). |
columnAccessor | Category → column band. |
segmentAccessor | Ordered value → vertical anchor position. |
repeatWords | Keep every occurrence (a word trails per segment) instead of merging to its peak. |
scaleToFit / packingDensity | Uniformly shrink all words until nothing overlaps; magnitude preserved. |
collisionPadding | Gap between word boxes (px). Toward 0 = crowded; higher opens the cloud up. |
wordColor / columnColor | Per-word and per-column fill overrides. Per-word callbacks receive the exact sourcedatum, its data and column indices, and the resolved column color. |
wordOpacity / weightOpacity | Progressive reveal without reflow: zero-opacity rows still reserve layout space but emit no glyph or hit target. Set weightOpacity=false when the callback should be the exact rendered opacity rather than multiply the default weight fade. |
wordTrailsProgressiveReveal | A config helper for stable playback: reached segments fade linearly, future segments remain in the layout but emit no glyph or hit target, and weight opacity is disabled unless explicitly combined. |
rotate | Max rotation magnitude in degrees (0 = all horizontal, most legible). |
showSegmentAxis / segmentAxisLabel | The labeled vertical value axis. |
Progressive traces without reflow
Spread wordTrailsProgressiveReveal into the layout config when the segment axis represents recorded runs, rounds, or checkpoints. The helper keeps the current segment fully present, fades reached history toward an authored floor, and hides future rows while their boxes continue to reserve space.
import { wordTrailsProgressiveReveal } from "semiotic/recipes" const layoutConfig = { ...baseConfig, repeatWords: true, ...wordTrailsProgressiveReveal({ currentSegment: currentRun, segmentDomain: [0, lastRun], oldestOpacity: 0.25, }), }
Source-aware, theme-aware encodings
wordColor and wordOpacity receive the retained source row asdatum. For words merged to their peak, that is the peak row. They also receiveresolvedColumnColor, so a second variable such as confidence, specificity, or distinctiveness can tint the column hue without rebuilding a lookup table. Returning CSS variables keeps the result responsive to light and dark theme changes without JavaScript theme detection.
const topicColors = { Methods: "var(--topic-methods)" } columnColor: (column) => topicColors[column], wordColor: ({ datum, resolvedColumnColor }) => "color-mix(in srgb, " + resolvedColumnColor + " " + datum.distinctiveness * 100 + "%, var(--semiotic-text))" /* Override category variables on the chart wrapper in either theme. */ [data-theme="light"] .my-word-trails { --topic-methods: #174f66; }
Transcripts: 2012 & 2016 via debates.org (Commission on Presidential Debates); 2020 via the m-arg dataset. Tokenized with stopwords removed and binned into 20 time segments; seedebateWordTrails.build.mjs for the exact pipeline.
Another honest axis:The Latent Crucible turns the columns into anonymous LDA topics and the vertical axis into Gibbs-sampling iterations, so the same recipe shows probability distributions congealing rather than speakers progressing through a debate.
Source Code
Abbreviated for readability — data arrays and steps are truncated. See the full runnable source in docs/src/examples/recipes/.
JSX
| 1 | import { OrdinalCustomChart } from "semiotic/ordinal" |
| 2 | import { wordTrailsLayout } from "semiotic/recipes" |
| 3 | |
| 4 | // One row per (speaker, word, segment) with the count in that segment. |
| 5 | // weight → font size, speaker → column, segment → vertical position. |
| 6 | const data = [ |
| 7 | { word: "jobs", speaker: "Clinton", segment: 3, weight: 6 }, |
| 8 | { word: "nuclear", speaker: "Clinton", segment: 18, weight: 5 }, |
| 9 | { word: "years", speaker: "Trump", segment: 2, weight: 7 }, |
| 10 | { word: "segment", speaker: "Holt", segment: 5, weight: 4 }, |
| 11 | // ...one row per word occurrence |
| 12 | ] |
| 13 | |
| 14 | export default function DebateWordTrails() { |
| 15 | return ( |
| 16 | <OrdinalCustomChart |
| 17 | data={data} |
| 18 | layout={wordTrailsLayout} |
| 19 | layoutConfig={{ |
| 20 | textAccessor: "word", |
| 21 | weightAccessor: "weight", |
| 22 | columnAccessor: "speaker", |
| 23 | segmentAccessor: "segment", |
| 24 | segmentDomain: [0, 19], |
| 25 | columnOrder: ["Clinton", "Holt", "Trump"], |
| 26 | // repeatWords: true, // let a word trail down its column per segment |
| 27 | scaleToFit: true, // shrink all words uniformly until nothing overlaps |
| 28 | segmentAxisLabel: "Debate timeline →", |
| 29 | }} |
| 30 | width={880} |
| 31 | height={580} |
| 32 | responsiveWidth |
| 33 | tooltip |
| 34 | /> |
| 35 | ) |
| 36 | } |
| 37 | |