<!-- GENERATED by scripts/build-llms.mjs from llms/data.md — do not edit this file. -->

# `lr-word-cloud`

- **Import** `import '@aceshooting/lyra-ui/components/lr-word-cloud.js';` (stable tag alias; registers the tag)
- **Class** `LyraWordCloud`, also available unregistered from `@aceshooting/lyra-ui/components/data/word-cloud/word-cloud.class.js`
- **Family** `components/data/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 12 parts, 8 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-word-cloud`

Dependency-free SVG word/tag cloud. First-party invention (no Web Awesome equivalent). Lays words
out via an outward Archimedean-spiral search — heaviest word placed first, each word spiraling from
the center until it clears every word already placed. Unlike sibling `lr-sparkline`/`lr-heatmap`
(one `role="img"` glyph standing in for an aggregate value), the individual words here _are_ the
meaningful interactive content — but with up to `MAX_WORDS` (150) of them, making every single one
its own tab stop would be a poor keyboard experience. Instead, like `lr-heatmap`'s cells, the whole
`[part="svg"]` is **one tab stop with roving arrow-key focus**: `ArrowRight`/`ArrowDown` move the
focus cursor to the next word in **declaration order** (not weight/placement order),
`ArrowLeft`/`ArrowUp` to the previous, `Home`/`End` to the first/last, and `Enter`/`Space` fires
`lr-word-activate` for the currently-focused word. Tab focus silently establishes the first word,
so immediate Enter/Space always works without requiring a preparatory arrow key. A
`[part="focus-ring"]` `<rect>` is drawn around the focused word, and a
shared light-DOM polite sink announces `"${text}, ${weight}"` on every focus move. Mount is silent,
and repeated edge movements append repeated announcements even when their text is identical.
`[part="live-region"]` mirrors the latest text for styling/inspection but is `aria-hidden` and has
no live-region role of its own. Pointer input resolves the nearest word from the adequately-sized
SVG surface; the potentially tiny text glyphs are not independent hit targets.

Pinned opposite-sign finite endpoints such as `[-1e308, 1e308]` produce bounded finite scale
fractions and SVG geometry, including with reversed endpoints or square-root scaling. Word weights
remain nonnegative; placement budgets, font-size limits, and the omitted-word policy are unchanged.

**Properties:**

- `words: readonly WordCloudWord[] = []` (attribute: false) — readonly `{ text: string, weight:
number, color?: string, group?: string }` snapshots; malformed/hostile records are skipped while
  later valid records survive. `weight` is normalized once to a finite nonnegative value used by
  font sizing, announcements, and `lr-word-activate` detail. A valid CSS `color` overrides the
  palette for that word (invalid values,
  declaration-breaking input, and `url()` fall back to the palette), and `group` shares one palette
  color across every word with the same `group` value. The component scans at most 10,000 input
  records, bounds each string to 256 characters and all retained word strings to 16,384 characters,
  marking shortened strings with an ellipsis and disclosing omitted input through `[part="limit"]`.
  The returned sequence and records are frozen; reassign `words` after changes.
- `minFontSize: number = 12` (attribute `min-font-size`) — px, applied to the lowest-weight word;
  a finite value is clamped to `[1, 512]` (so `0`/a negative value floors at `1px`, and an oversized
  value caps at `512px`); a non-finite value (`NaN`/`Infinity`) falls back to the default `12px`
  rather than to the `1px` floor
- `maxFontSize: number = 48` (attribute `max-font-size`) — px, applied to the highest-weight word;
  clamped/defaulted the same way (a non-finite value falls back to `48px`, not to `1px`); a
  resulting reversed pair (`minFontSize` greater than `maxFontSize`) is swapped rather than
  inverting the weight-to-size mapping
- `domain?: [number, number]` (attribute: false) — pins the weight-to-font-size input domain so
  separate clouds can share one scale instead of each deriving it from its own lightest and
  heaviest words. Reversed endpoints are normalized; a degenerate or non-finite pair falls back to
  the data-derived range
- `scale: 'linear'|'sqrt' = 'linear'` — `sqrt` compresses the weight→font-size mapping so one heavy
  word doesn't dwarf the rest, matching `lr-heatmap`'s `scale` property
- `wordRotation: 'none'|'mixed' = 'none'` (attribute `word-rotation`, reflected) — `mixed` lets
  ~25% of words render rotated 90° for denser packing
- `palette?: readonly string[]` (attribute: false) — clone-owned custom categorical colors (at
  most 64), cycled by word index (or by
  `group`); invalid CSS colors, declaration-breaking input, and `url()` entries are skipped, and an
  all-invalid palette defaults to the `--lr-word-cloud-color-1..8` tokens. The returned sequence is
  frozen; reassign `palette` after changes
- `legend: readonly WordCloudLegendItem[] = []` (attribute: false) — clone-owned, frozen named
  readonly `{ label, color }` entries for explaining explicit `words[].color`/group color
  overrides; when omitted, the component derives entries from grouped and explicitly colored
  words. Explicit legends retain at most 100 entries and 8,192 aggregate characters; malformed
  records are skipped, overlong strings end in an ellipsis, invalid colors render transparent, and
  `[part="legend-limit"]` truthfully exposes the localized rendered/received count. The returned
  sequence and records are frozen; reassign `legend` after changes.
- `showLegend: boolean = false` (attribute `show-legend`, reflected) — renders the supplied or
  derived legend below the cloud; the color key is an accessible list and does not change word
  activation or palette selection

**Methods:** `refreshTheme(): void` — forces a relayout so the `--lr-font` custom property is
re-read from computed style (font-family affects the canvas text measurement layout depends on).
The component's theme watcher calls it automatically when inherited theme typography changes; the
method remains available for a host theme system that needs an explicit synchronous refresh.

**Events:** `lr-word-activate` (frozen readonly `detail: { text, weight, group }`; fires from the
single SVG pointer surface, or Enter/Space on the current word)

**Slots:** none.

**CSS parts:** `base`, `svg`, `word` (each `<text>`), `focus-ring` (the rect around the roving-focus
cursor's word), `live-region` (visually-hidden, `aria-hidden` mirror of the latest announcement;
the actual announcement uses the shared light-DOM polite sink),
`legend`/`legend-item`/`legend-swatch`/`legend-label` (the optional static color key), and `empty`
(the no-data placeholder), `limit` (localized rendered/received word count), and `legend-limit`
(localized rendered/received explicit-legend count)

**Themeable custom properties:** `--lr-word-cloud-color-1`, `--lr-word-cloud-color-2`,
`--lr-word-cloud-color-3`, `--lr-word-cloud-color-4`, `--lr-word-cloud-color-5`,
`--lr-word-cloud-color-6`, `--lr-word-cloud-color-7`, `--lr-word-cloud-color-8` (the default
categorical palette, cycled by word index or `group`; a data-driven literal exception like
`lr-heatmap`'s scale-ramp endpoints — exposed as retheme-able custom properties instead of
hardcoded). They inherit from theme ancestors, while a value set directly on the word cloud wins
through the normal cascade), plus shared tokens (`--lr-font`,
`--lr-focus-ring-*`, `--lr-transition-fast`, `--lr-color-text-quiet`).

**Optional peer deps:** none.

```html
<lr-word-cloud id="cloud" style="height: 20rem"></lr-word-cloud>
<script type="module">
  document.getElementById("cloud").words = [
    { text: "JavaScript", weight: 90 },
    { text: "TypeScript", weight: 75 },
    { text: "Lit", weight: 60, group: "framework" },
  ];
  document
    .getElementById("cloud")
    .addEventListener("lr-word-activate", (e) => console.log(e.detail));
</script>
```

The focusable SVG is the single semantic owner: `role="application"` plus an accessible name. An
authored host `aria-label` is forwarded to that SVG and wins by attribute presence; when it is
absent, the SVG uses an auto-computed localized name such as `"Word cloud of 12 words"` / `"Word
cloud of 1 word"`, counting only words actually rendered. An authored host `role` remains on the
host and does not replace the SVG's application role. When records are omitted, the SVG references
the visible localized `[part="limit"]` rendered/received summary.

**Known gotchas:**

- capped at 150 words (`MAX_WORDS` in `word-cloud-layout.ts`, mirroring `lr-sparkline`'s
  `MAX_POINTS` input-sample guard) — a one-pass bounded top-K scan retains the **heaviest** 150 and
  counts the rest without cloning/sorting/spreading the full input. A pathological input can exhaust the
  spiral search's radius bound and get dropped the same way; blank/whitespace-only `text` is dropped
  during boundary normalization. Omitted diagnostics retain at most 32 records, while a separate
  complete count drives one deduplicated `console.warn` and the rendered disclosure — nothing throws.
- each word's spiral search tests at most 4,096 candidate positions. Together with the 150-word and
  512px font-size caps, this bounds placement work even for dense or adversarial layouts; a word
  that exhausts the search budget is reported through the same skipped-word path.
- text width is measured via a detached `<canvas>` 2D context (`ctx.measureText`) using the live
  `--lr-font-weight-semibold` and `--lr-font` token values. A consumer-only `::part(word)` font
  override can still desynchronize measurement from the painted glyph.
- rotation (`word-rotation="mixed"`) is genuinely random per layout (`Math.random()`, not seeded), so
  which words render rotated changes on every re-layout (any `words`/`minFontSize`/`maxFontSize`/
  `scale`/`wordRotation` change) — don't rely on rotation being stable across renders.
- only one word is ever in the page's tab sequence at a time (the roving cursor on `[part="svg"]`) —
  there's no way to Tab directly to the Nth word; arrow-key/Home/End your way there, or click it.

---
