Reactで、文章によく出る言葉をワードクラウドで表示する方法

​​React​Canvas,Intl.Segmenter,Playwright,TypeScript

ワードクラウドは、単語を数えたあと、回数から文字の大きさを決め、大きい語から順に重ならない場所へ置いていけば作れます。置き場所は、枠の真ん中からうず巻きの線に沿って外へ探していきます。重ならないかを判定するには、描く前に文字の幅が分かっている必要があるので、画面に出さない 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 フォントを読み込んで使う場合や、縦書きや斜めに回した語、枠の幅を画面に合わせて変える作り、サーバーでのレンダリングも扱っていません。

上に文章の入力欄があり、猫が窓辺で眠る見本の文章が入っている。その下の薄い水色の枠の真ん中に、ピンクの大きな「猫」。まわりに「眠り」「窓辺」「小鳥」「庭」「外」「見」「来」「窓」がやや大きく、「朝」「台所」「待ち」「毛布」などが小さく並び、どの語も重なっていない。枠の下に「20語を表示」
見本の文章をワードクラウドにしたところ(幅 400px の画面)

回数から、文字の大きさを決める

文字の大きさは、いちばん少ない回数の語を 14px、いちばん多い回数の語を 56px にして、その間を割り振ります。文字の大きさを回数にそのまま比例させると、真ん中くらいの回数の語は、見た目の広さが回数の割に小さくなります。文字は縦と横の両方に大きくなるので、見た目の広さ(面積)は文字の大きさの2乗で増えるからです。

そこで、回数の平方根で割り振る 'sqrt' を既定にしました。比べるために、回数をそのまま使う 'linear' も選べるようにしています。

ts
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 で大きさを計算しました。

text
回数        : 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回の語との差が分かりやすくなっています。

text
見本の文章 6/3/2/1回: linear 56 31 22 14 / sqrt 56 35 26 14
2つのワードクラウドを縦に並べた画面。上は「回数に比例(linear)」、下は「回数の平方根に比例(sqrt)」。どちらも真ん中にピンクの「猫」がある。下のほうが「窓辺」「小鳥」「庭」「来」「見」などの2回の語が大きく、小さな1回の語との差がはっきりしている
同じ文章を、回数に比例させた場合(上)と、回数の平方根に比例させた場合(下)

うず巻きの線に沿って、重ならない場所を探す

並べ方は layout() にまとめました。単語は、回数の多い順に受け取ります。1語ずつ、枠の真ん中から始めて、少しずつ外へ広がるうず巻きの線の上を動かします。そして、すでに置いた語のどれとも重ならず、枠からもはみ出さない最初の位置に置きます。

ts
// 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() の結果に大きさと位置を足したものです。

ts
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回並べたところ、結果は同じでした。

text
  猫 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 のページ)。描くときと同じ太さ・大きさ・フォントを指定して、そろえています。

tsx
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 件でした。

text
canvas で測った幅 − 表示の幅: 最小 0.00 px 最大 0.98 px

読み上げ用に、回数を文字で入れる

見た目では文字の大きさで回数を伝えていますが、それは読み上げでは伝わりません。そこで、全体を「よく出る言葉(多い順)」という名前のリストにし、各語の後ろに「(6回)」のような文字を、見た目には出さずに入れました。.visually-hidden は、次の CSS で 1px の大きさに切り取っています。

css
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}

position: absolute にしているので、この <span> は <li> の幅に入りません。Playwright でアクセシビリティツリーを読むと、多い順のリストになっていました。

text
- list "よく出る言葉(多い順)":
  - listitem: 猫 (6回)
  - listitem: 眠り (3回)
  - listitem: 窓辺 (2回)
  - listitem: 来 (2回)
  - listitem: 窓 (2回)

入力欄とつなぐ

入力欄の文章は state に持ち、単語の回数はレンダーのたびに countWords() で計算して WordCloud に渡します。?max=32 の行は、あとで文字の大きさを変えて確かめるために足したものです。

tsx
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 語でした。

text
--- 長い英単語 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 語を表示できました。省かれる語が多いときは、文字を小さくすると枠に入る語が増えます。

text
--- 長い英単語 40 種類(いちばん大きい文字を 32px に)
caption : 24語を表示(枠に入りきらなかった16語は省略)

layout() にかかる時間は、Chromium の中で測りました。3回空回ししてから11回測った結果です。

text
見本の文章(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 を依存配列に入れても、中身が同じかどうかにかかわらず計算し直しになり、メモ化しても計算は減りません。

​Posted by 86