Reactで、文章によく出る言葉をワードクラウドで表示する方法
ワードクラウドは、単語を数えたあと、回数から文字の大きさを決め、大きい語から順に重ならない場所へ置いていけば作れます。置き場所は、枠の真ん中からうず巻きの線に沿って外へ探していきます。重ならないかを判定するには、描く前に文字の幅が分かっている必要があるので、画面に出さない canvas で測ります。
ここでは、多い順に並んだ単語と回数を渡すと、幅 360px・高さ 240px の枠に並べる WordCloud というコンポーネントを作りました。単語を数える部分は、以前の記事「Reactで、文章に出てくる単語の回数を数える方法」の countWords() をそのまま使い、ひらがなだけの語は数えない設定にしています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 で、文字は Noto Sans CJK JP です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で Chromium に表示して確かめました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの実際の読み上げは試していません。Web フォントを読み込んで使う場合や、縦書きや斜めに回した語、枠の幅を画面に合わせて変える作り、サーバーでのレンダリングも扱っていません。

回数から、文字の大きさを決める
文字の大きさは、いちばん少ない回数の語を 14px、いちばん多い回数の語を 56px にして、その間を割り振ります。文字の大きさを回数にそのまま比例させると、真ん中くらいの回数の語は、見た目の広さが回数の割に小さくなります。文字は縦と横の両方に大きくなるので、見た目の広さ(面積)は文字の大きさの2乗で増えるからです。
そこで、回数の平方根で割り振る 'sqrt' を既定にしました。比べるために、回数をそのまま使う 'linear' も選べるようにしています。
export type Scale = 'linear' | 'sqrt';
// 回数を minPx〜maxPx の文字の大きさにする。'sqrt' は平方根で差を縮める
export function fontSizeOf(
count: number, minCount: number, maxCount: number,
minPx: number, maxPx: number, scale: Scale = 'sqrt',
): number {
if (maxCount === minCount) return maxPx; // 全部同じ回数なら、全部いちばん大きく
const f = scale === 'sqrt' ? Math.sqrt : (n: number) => n;
const t = (f(count) - f(minCount)) / (f(maxCount) - f(minCount));
return Math.round(minPx + (maxPx - minPx) * t);
}
回数が 12、6、3、2、1 回の語について、Node.js で大きさを計算しました。
回数 : 12 6 3 2 1
linear (px) : 56 33 22 18 14
sqrt (px) : 56 39 26 21 14
6回の語の面積(12回の語を1とする): linear 0.35 / sqrt 0.49
回数が12回の語の半分にあたる6回の語は、'linear' では面積が 0.35 倍、'sqrt' では 0.49 倍になりました。'sqrt' のほうが、回数の比と見た目の広さの比が近くなります。全部同じ回数のときは、0 で割ることにならないように、全部を 56px にしています。
見本の文章(猫が6回、眠りが3回、2回の語が7語、1回の語が11語)で並べると、次のようになりました。'sqrt' では、2回の語が 22px から 26px に大きくなり、1回の語との差が分かりやすくなっています。
見本の文章 6/3/2/1回: linear 56 31 22 14 / sqrt 56 35 26 14

うず巻きの線に沿って、重ならない場所を探す
並べ方は layout() にまとめました。単語は、回数の多い順に受け取ります。1語ずつ、枠の真ん中から始めて、少しずつ外へ広がるうず巻きの線の上を動かします。そして、すでに置いた語のどれとも重ならず、枠からもはみ出さない最初の位置に置きます。
// 2つの箱(x, y は左上)が、gap の隙間を空けても重なるか
function overlaps(a: Placed, b: Placed, gap: number): boolean {
return a.x < b.x + b.width + gap && b.x < a.x + a.width + gap
&& a.y < b.y + b.height + gap && b.y < a.y + a.height + gap;
}
// 大きい語から順に、真ん中からうず巻きの線に沿って外へ動かし、
// どの語とも重ならず、枠からもはみ出さない最初の位置に置く
export function layout(
words: readonly Sized[], measure: (word: string, fontSize: number) => Size,
area: Size, gap = 2, step = 3,
): { placed: Placed[]; skipped: Sized[] } {
const placed: Placed[] = [];
const skipped: Sized[] = [];
const cx = area.width / 2;
const cy = area.height / 2;
const aspect = area.width / area.height; // 横長の枠では、横に広がるうず巻きにする
const maxR = Math.hypot(cx, cy);
for (const w of words) {
const { width, height } = measure(w.word, w.fontSize);
let spot: Placed | null = null;
// アルキメデスのらせん r = (step / 2π)θ。1周で step だけ外へ出る
for (let theta = 0, r = 0; r <= maxR; ) {
const box: Placed = {
...w, width, height,
x: cx + r * Math.cos(theta) * aspect - width / 2,
y: cy + r * Math.sin(theta) - height / 2,
};
const inside = box.x >= 0 && box.y >= 0
&& box.x + width <= area.width && box.y + height <= area.height;
if (inside && placed.every((p) => !overlaps(box, p, gap))) {
spot = box;
break;
}
theta += step / Math.max(r, step); // 線の上を、だいたい step ずつ進む
r = (step / (2 * Math.PI)) * theta;
}
if (spot) placed.push(spot);
else skipped.push(w);
}
return { placed, skipped };
}
型は、countWords() の結果に大きさと位置を足したものです。
export type Size = { width: number; height: number };
export type Sized = WordCount & { fontSize: number };
export type Placed = Sized & { x: number; y: number; width: number; height: number };
うず巻きには、中心からの距離 r が回った角度 theta に比例する線を使っています。この線は、1周するごとに同じ幅(ここでは step の 3px)だけ外へ出るので、内側から外側まで同じ細かさで探せます。ただし、横長の枠に合わせて、横の向きには1.5 倍(360 ÷ 240)に引き伸ばしています。角度を一定ずつ増やすと、外側ほど1回で進む距離が長くなり、探す点の間隔が粗くなります。そこで、角度を step / max(r, step) ずつ増やして、引き伸ばす前の線の上を約 3px ずつ進むようにしました(中心の近くで r が step より小さいうちは、1ラジアンずつ増やします)。
重なりは、語を囲む長方形どうしで判定しています。「眠り」の「り」の左右のような、文字の形のすき間には、ほかの語は入りません。
乱数を使っていないので、同じ単語と回数を渡せば、毎回同じ位置に並びます。Node.js で、仮の幅(1文字の幅を文字の大きさと同じとみなす)を使って2回並べたところ、結果は同じでした。
猫 x=152.0 y=86.0 w=56 h=68
窓辺 x=73.3 y=104.6 w=76 h=46
眠り x=210.3 y=82.3 w=76 h=46
庭 x=212.8 y=131.6 w=28 h=34
同じ入力で2回並べて同じ結果: true
文字の幅を canvas で測って、React で描く
重なりを判定するには、描く前に文字の幅が要ります。layout() は幅を測る関数 measure を引数で受け取るので、ブラウザでは canvas を使う関数を渡します。
canvas の measureText() は、文字列の幅などを TextMetrics として返します(MDN の measureText() のページ)。文字の形は font で決まり、CSS の font と同じ書き方の文字列を入れます(MDN の font のページ)。描くときと同じ太さ・大きさ・フォントを指定して、そろえています。
import type { WordCount } from './words';
import { fontSizeOf, layout, type Scale } from './cloud';
const FONT = '"Noto Sans CJK JP", "Hiragino Sans", sans-serif';
const LINE_HEIGHT = 1.2;
// 文字の幅は、画面に出さない canvas で測る(最初に1回だけ作る)
let ctx: CanvasRenderingContext2D | null = null;
function measureText(word: string, fontSize: number) {
ctx ??= document.createElement('canvas').getContext('2d')!;
ctx.font = `bold ${fontSize}px ${FONT}`;
return {
width: Math.ceil(ctx.measureText(word).width),
height: Math.ceil(fontSize * LINE_HEIGHT),
};
}
type Props = {
words: WordCount[]; // 多い順に並んだ単語と回数
width: number;
height: number;
limit?: number; // 表示する語の数の上限
scale?: Scale;
maxFontSize?: number; // いちばん多い語の文字の大きさ(px)
};
export function WordCloud({ words, width, height, limit = 40, scale = 'sqrt', maxFontSize = 56 }: Props) {
// 並べた結果は state にせず、レンダーのたびに props から計算する
const top = words.slice(0, limit);
const max = top[0]?.count ?? 0;
const min = top[top.length - 1]?.count ?? 0;
const sized = top.map((w) => ({ ...w, fontSize: fontSizeOf(w.count, min, max, 14, maxFontSize, scale) }));
const { placed, skipped } = layout(sized, measureText, { width, height });
return (
<figure className="word-cloud">
<ul aria-label="よく出る言葉(多い順)" style={{ position: 'relative', width, height }}>
{placed.map((w, i) => (
<li
key={w.word}
style={{
position: 'absolute', left: w.x, top: w.y,
fontSize: w.fontSize, lineHeight: LINE_HEIGHT,
fontFamily: FONT, fontWeight: 'bold', whiteSpace: 'nowrap',
color: i === 0 ? '#d85878' : i % 2 ? '#067a98' : '#1f2a30',
}}
>
{w.word}
<span className="visually-hidden">({w.count}回)</span>
</li>
))}
</ul>
<figcaption>
{placed.length}語を表示
{skipped.length > 0 && `(枠に入りきらなかった${skipped.length}語は省略)`}
</figcaption>
</figure>
);
}
各語は <li> にして、position: absolute で layout() が決めた左上の位置に置きます。高さは line-height を 1.2 にそろえ、測るときも文字の大きさの 1.2 倍にしています。
Chromium で、canvas で測った幅(切り上げたもの)と、画面に出た <li> の幅を比べると、差は 0〜0.98px でした。測った幅が、実際の幅より小さくなることはありませんでした。表示された <li> の長方形どうしで数えても、重なりと枠からのはみ出しは 0 件でした。
canvas で測った幅 − 表示の幅: 最小 0.00 px 最大 0.98 px
読み上げ用に、回数を文字で入れる
見た目では文字の大きさで回数を伝えていますが、それは読み上げでは伝わりません。そこで、全体を「よく出る言葉(多い順)」という名前のリストにし、各語の後ろに「(6回)」のような文字を、見た目には出さずに入れました。.visually-hidden は、次の CSS で 1px の大きさに切り取っています。
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
position: absolute にしているので、この <span> は <li> の幅に入りません。Playwright でアクセシビリティツリーを読むと、多い順のリストになっていました。
- list "よく出る言葉(多い順)":
- listitem: 猫 (6回)
- listitem: 眠り (3回)
- listitem: 窓辺 (2回)
- listitem: 来 (2回)
- listitem: 窓 (2回)
入力欄とつなぐ
入力欄の文章は state に持ち、単語の回数はレンダーのたびに countWords() で計算して WordCloud に渡します。?max=32 の行は、あとで文字の大きさを変えて確かめるために足したものです。
import { useId, useState } from 'react';
import { countWords } from './words';
import { WordCloud } from './WordCloud';
const SAMPLE =
'猫は窓辺で眠ります。朝になると、猫は台所に来て、ごはんを待ちます。' +
'ごはんを食べた猫は、また窓辺に戻って眠ります。' +
'雨の日の猫は、窓の外をじっと見ています。窓の外には庭があり、庭には小鳥が来ます。' +
'小鳥を見つけた猫は、しっぽを振ります。夜になると、猫は毛布の上で眠ります。';
export function App() {
const [text, setText] = useState(SAMPLE);
const textId = useId();
const words = countWords(text, true); // ひらがなだけの語は数えない
// 確かめるときだけ、?max=32 で最大の文字の大きさを変えられるようにした
const max = Number(new URLSearchParams(location.search).get('max')) || 56;
return (
<main>
<label htmlFor={textId}>文章</label>
<textarea id={textId} rows={5} value={text} onChange={(e) => setText(e.target.value)} />
<WordCloud words={words} width={360} height={240} maxFontSize={max} />
</main>
);
}
Playwright で文章の最後に「窓辺の猫は、小鳥を見ています。」と入力すると、窓辺・小鳥・見が3回になって、26px から 33px に大きくなりました。英語の文章 cat dog cat bird cat dog fish に入れ替えると、cat が 56px、dog が 38px で、4語を表示しました。文章を空にすると、「0語を表示」になりました。どの場合も、重なりと枠からのはみ出しは 0 件で、コンソールにエラーや警告は出ませんでした。
枠に入りきらない語と、並べる時間
うず巻きを枠の角まで探しても置き場所がない語は、表示せずに skipped に入れ、枠の下に何語を省いたかを出しています。英単語の末尾に a〜d を付けた長い語を 40 種類(1位の語は 40 回、2位は 20 回のように、順位が下がるほど少なくなる)入れたところ、表示できたのは 10 語でした。
--- 長い英単語 40 種類
caption : 10語を表示(枠に入りきらなかった30語は省略)
words : strawberrya(56px) blueberrya(41px) raspberrya(36px) watermelona(28px) grapefruita(25px) cranberrya(22px) cranberryb(17px) blueberryc(17px) raspberryc(17px) cranberryd(14px)
回数が4位の pineapplea(10回)は省かれ、それより回数の少ない cranberrya などは入りました。layout() は、置けない語を飛ばして次の語へ進むからです。いちばん大きい文字を 32px に下げると、同じ文章で 24 語を表示できました。省かれる語が多いときは、文字を小さくすると枠に入る語が増えます。
--- 長い英単語 40 種類(いちばん大きい文字を 32px に)
caption : 24語を表示(枠に入りきらなかった16語は省略)
layout() にかかる時間は、Chromium の中で測りました。3回空回ししてから11回測った結果です。
見本の文章(20語、置けた 20 語): 中央値 2.4ms 最小 1.7ms 最大 3.1ms
長い英単語 40 種類(40語、置けた 10 語): 中央値 37.8ms 最小 36.8ms 最大 43.6ms
長い語の場合のほうが、時間が1桁以上長くなりました。語の数が2倍なことに加えて、置けない語は1語ごとにうず巻きを最後までたどってから省かれるので、置けない語が多いと時間が長くなります。
このコンポーネントは、1文字入力するたびに並べ直します。React の公式リファレンスでは、計算にかかる時間が合計で 1ms 以上になるなら、useMemo でメモ化を考えてよいとしています。上の時間は、React を通さずに layout() だけを測った値です。ここでは useMemo は使っていません。useMemo は依存配列の値を前回の値と Object.is で比べますが、App の countWords() はレンダーのたびに新しい配列を返します。そのため、words を依存配列に入れても、中身が同じかどうかにかかわらず計算し直しになり、メモ化しても計算は減りません。





