Reactで、並べ替えの動きを棒グラフで見せる方法

​​React​CSS,Playwright,StrictMode,TypeScript,useEffect

並べ替えの途中の様子を、棒グラフで1手ずつ見せる部品を React で作りました。2本の棒を比べるたびに色を変え、入れ替えるときは棒が横にすべって位置が替わります。再生と一時停止、1手ずつ進めるボタンも付けています。

作り方の要点は2つです。1つ目は、並べ替えを先に最後まで実行して、1手ごとの並びを配列に記録しておくことです。画面は、その配列の何番目を表示するかだけを state に持ちます。2つ目は、棒を動かすときの DOM の扱いです。key の付け方と DOM の並べ方によっては、棒がすべらずに一瞬で入れ替わります。その違いは、検証環境で棒の位置を測って確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。並べ替えの方法はバブルソートだけを扱います。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げは試していません。

10本の棒グラフ。「1手進める」を押すと、左の2本(5と9)がピンクになる。もう一度押すと、9と2がピンクになり、9が右へ、2が左へすべって入れ替わる。続けて「再生」を押すと、ピンクの2本が右へ移りながら比べて入れ替えを続け、いちばん右に来た10が濃い水色になる。下の帯には「クリック n回目/3回」と出る
「1手進める」で1手ずつ、「再生」で続けて並べ替えが進む様子

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。回数の表示が変わった瞬間だけピンクにする仕掛けも、撮影のときだけ足しています。撮影では、棒の色の切り替えに途中の色が写らないように、棒の背景色(background-color)の transition を外しています。

並べ替えの途中を、1手ずつ配列に記録する

筆者は、並べ替えを先に最後まで終わらせ、1回比べるごとの並びを Step として配列に残す作りにしました。画面のほうは、この配列の何番目を表示するかを変えるだけなので、一時停止も1手ずつ進める操作も、表示する番号を進めるのを止めるか、1つ進めるかだけで済みます。

ts
export type Bar = { id: number; value: number };

export type Step = {
  bars: Bar[]; // この時点での並び
  comparing: [number, number] | null; // 比べている2本の位置
  swapped: boolean; // この手で入れ替えたか
  sortedFrom: number; // この位置から右は並べ終わっている
  compares: number; // ここまでに比べた回数
  swaps: number; // ここまでに入れ替えた回数
};

// バブルソートを最後まで進め、1回比べるごとの様子を配列にして返す
export function bubbleSortSteps(input: Bar[]): Step[] {
  const bars = [...input];
  const n = bars.length;
  let compares = 0;
  let swaps = 0;
  const steps: Step[] = [
    { bars: [...bars], comparing: null, swapped: false, sortedFrom: n, compares, swaps },
  ];
  for (let end = n - 1; end > 0; end--) {
    for (let i = 0; i < end; i++) {
      compares++;
      const swapped = bars[i].value > bars[i + 1].value;
      if (swapped) {
        [bars[i], bars[i + 1]] = [bars[i + 1], bars[i]];
        swaps++;
      }
      steps.push({ bars: [...bars], comparing: [i, i + 1], swapped, sortedFrom: end + 1, compares, swaps });
    }
  }
  steps.push({ bars: [...bars], comparing: null, swapped: false, sortedFrom: 0, compares, swaps });
  return steps;
}

// 1〜n の値を Fisher–Yates で混ぜた棒を作る
export function shuffledBars(n: number): Bar[] {
  const values = Array.from({ length: n }, (_, i) => i + 1);
  for (let i = n - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [values[i], values[j]] = [values[j], values[i]];
  }
  return values.map((value, id) => ({ id, value }));
}

棒には、値とは別に id を持たせています。同じ値の棒があっても見分けられるようにするためと、後で key に使うためです。bars は記録したあとも入れ替えを続けるので、記録するときは1手ごとに [...bars] で写しを作ります。

GIF と同じ 5 9 2 7 1 8 3 10 6 4 の並びを Node.js で渡すと、記録は次のようになりました。

text
手の数(最初と最後を含む): 47
比べた回数: 45  入れ替えた回数: 22  転倒数(別に数えた値): 22
最後の並び: 1 2 3 4 5 6 7 8 9 10
最初の5手:
  5 9 2 7 1 8 3 10 6 4 null 
  5 9 2 7 1 8 3 10 6 4 [ 0, 1 ] 
  5 2 9 7 1 8 3 10 6 4 [ 1, 2 ] 入れ替え
  5 2 7 9 1 8 3 10 6 4 [ 2, 3 ] 入れ替え
  5 2 7 1 9 8 3 10 6 4 [ 3, 4 ] 入れ替え
  5 2 7 1 8 9 3 10 6 4 [ 4, 5 ] 入れ替え
入力はそのまま: 5 9 2 7 1 8 3 10 6 4

比べた回数は、10本なら 10×9÷2 で45回です。本数を n とすると n×(n−1)÷2 で、30本・100本でもこの式と同じ回数でした。入れ替えた回数の22回は、左にあるほうが大きい組の数(転倒数)を、別のループで数えた値と一致しました。バブルソートは隣どうしの入れ替えで、この組を1つずつ解消していくからです。渡した配列は書き換わっていません。

1手ごとに並び全体を写すので、記録は本数が増えると急に大きくなります。JSON の文字列にした長さで比べると、10本で約1万3千文字、30本で約31万文字、100本では約1,076万文字でした。100本では4,952手になり、どの手も100本ぶんの並びを持つためです。

再生は useEffect のタイマーで1手ずつ進める

画面の部品は次のとおりです。いま表示している手の番号 index と、再生中かどうかの playing を state に持ちます。

tsx
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { bubbleSortSteps, shuffledBars, type Bar } from './sortSteps';

const SLOT = 32; // 1本ぶんの横幅(px)
const DELAY = 400; // 1手を表示しておく時間(ms)

type Props = { initial?: Bar[]; count?: number };

export function SortVisualizer({ initial, count = 10 }: Props) {
  const [bars, setBars] = useState<Bar[]>(() => initial ?? shuffledBars(count));
  const steps = useMemo(() => bubbleSortSteps(bars), [bars]);
  const [index, setIndex] = useState(0);
  const [playing, setPlaying] = useState(false);

  const step = steps[index];
  const done = index === steps.length - 1;
  const max = Math.max(...bars.map((b) => b.value));

  // 再生中は、DELAY ごとに1手ずつ進める
  useEffect(() => {
    if (!playing || done) return;
    const timer = setTimeout(() => setIndex((i) => i + 1), DELAY);
    return () => clearTimeout(timer);
  }, [playing, done, index]);

  const handlePlay = () => {
    if (done) setIndex(0);
    setPlaying(true);
  };
  const handleShuffle = () => {
    setBars(shuffledBars(count));
    setIndex(0);
    setPlaying(false);
  };

  // id から「いま何番目にあるか」を引けるようにする
  const position = new Map(step.bars.map((b, i) => [b.id, i]));

  return (
    <div className="sv">
      <div className="sv-chart" style={{ width: bars.length * SLOT }}>
        {/* DOM の並びは id の順のまま変えず、位置は transform だけで動かす */}
        {bars.map((bar) => {
          const i = position.get(bar.id)!;
          const state =
            step.comparing?.includes(i) ? 'comparing' : i >= step.sortedFrom ? 'sorted' : 'idle';
          const style: CSSProperties = {
            height: `${(bar.value / max) * 100}%`,
            width: SLOT - 6,
            transform: `translateX(${i * SLOT}px)`,
          };
          return (
            <div key={bar.id} className="sv-bar" data-state={state} style={style}>
              <span>{bar.value}</span>
            </div>
          );
        })}
      </div>

      <p className="sv-status">
        比べた回数 {step.compares}、入れ替えた回数 {step.swaps}
        <br />
        {done ? '並べ終わりました。' : step.swapped ? '入れ替えました。' : ''}
      </p>

      <div className="sv-buttons">
        {playing && !done ? (
          <button type="button" onClick={() => setPlaying(false)}>一時停止</button>
        ) : (
          <button type="button" onClick={handlePlay}>{done ? 'もう一度再生' : '再生'}</button>
        )}
        <button type="button" onClick={() => setIndex((i) => i + 1)} disabled={playing || done}>
          1手進める
        </button>
        <button type="button" onClick={handleShuffle}>並べ直す</button>
      </div>
    </div>
  );
}

再生には setInterval ではなく、1手ごとに setTimeout を1つだけ仕掛ける書き方を使いました。依存配列に index を入れているので、1手進むたびに前のタイマーを片付けてから、次のタイマーを仕掛け直します。最後の手まで来ると done が true になり、タイマーを仕掛けなくなります。止めるための state の書き換えは要りません。

公式リファレンスの useEffect の説明にあるとおり、StrictMode では開発時だけ、マウントしたときにセットアップとクリーンアップが本物のセットアップの前に1回余分に実行されます。この部品はマウントの時点では playing が false なので、余分に実行されてもタイマーは仕掛けられません。再生を始めたあとは、index が変わるたびにクリーンアップの clearTimeout で前のタイマーを消してから、次のタイマーを仕掛けます。StrictMode で包んだ開発用のビルドで、再生中の表示が変わった時刻を記録すると、比べた回数は4、5、6、7、8と1つずつ増え、間隔は403〜404ms でした。一度に2手進むことはありませんでした。

一時停止を押したあとは、1.5秒待っても表示は「比べた回数 8」のままでした。最後まで再生すると「比べた回数 45、入れ替えた回数 22」「並べ終わりました。」と出て、10本すべてが並べ終えた色(濃い水色)になります。このとき「1手進める」は押せなくなり、再生のボタンは「もう一度再生」に変わります。押すと最初の並びに戻って再生が始まりました。

棒をすべらせるには、DOM の並びを変えない

棒は position: absolute で同じ場所に重ね、transform: translateX() で横の位置を決めています。CSS で transform に transition を付けておけば、位置が変わったときに棒がすべって動きます。

ここで気をつけるのが、棒を描く順番と key です。記事のコードでは、DOM の並びは bars(id の順)のまま変えず、いまの位置は transform にだけ反映しています。3通りの描き方を比べるために、3本の棒(9、3、6)の先頭の2本を入れ替えて、150ms 後の位置を getComputedStyle で読みました。動き終わったときの位置は、3 が 0px、9 が 32px です。

描き方 150ms 後の位置 動き方
key に配列の位置を使い、いまの並びの順に描く 3=0px、9=32px どちらも一瞬で入れ替わる
key に id を使い、いまの並びの順に描く 3=6.3px、9=32px 3 はすべるが、9 は一瞬で移る
key に id を使い、DOM は id の順のまま描く 9=23.7px、3=8.3px 2本ともすべる

key に配列の位置を使うと、要素はそのまま残り、中の数字と高さだけが替わりました。棒が動いたのではなく、別の棒に描き替わったように見えます。公式ドキュメントの「リストのレンダー」のページでも、並び替えのある配列でインデックスを key に使うと、ややこしいバグの原因になると書かれています。

key に id を使い、いまの並びの順に描いた場合は、React が要素の1つ(ここでは 9)を DOM の中で移しました。検証環境では、DOM の中で移された 9 は一瞬で動き終わった位置に移り、もう1本の 3 だけがすべりました。入れ替えのたびに片方の棒が飛んで見えます。

DOM の並びを id の順に固定すると、要素は移されず、transform の値だけが変わります。2本とも途中の位置にあり、それぞれ反対の向きにすべっていました。GIF でも、入れ替える2本が重なりながらすれ違っています。

CSS は次のとおりです。比べている2本はピンク、並べ終えた棒は濃い水色にしています。

css
.sv { display: grid; gap: 12px; font-size: 15px; color: #1f2a30; }
.sv-chart { position: relative; height: 200px; border-bottom: 2px solid #1f2a30; }
.sv-bar {
  position: absolute; left: 3px; bottom: 0;
  display: flex; align-items: flex-end; justify-content: center;
  background: #00c8e8; border-radius: 4px 4px 0 0;
  transition: transform 300ms ease, background-color 150ms;
}
.sv-bar span { font-size: 12px; padding-bottom: 4px; }
.sv-bar[data-state='comparing'] { background: #d85878; }
.sv-bar[data-state='sorted'] { background: #067a98; color: #fff; }
.sv-status { margin: 0; min-height: 3em; line-height: 1.5; }
.sv-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.sv-buttons button { padding: 8px 14px; font: inherit; }
@media (prefers-reduced-motion: reduce) {
  .sv-bar { transition: none; }
}

transition の 300ms は、1手を表示しておく 400ms より短くしています。次の手に進む前に、すべる動きが終わるようにするためです。検証環境では、入れ替えから 550ms 後に、2本とも動き終わった位置(96px と 64px)に止まっていました。

最後の @media は、端末で動きを減らす設定をしている人のためのものです。MDN の prefers-reduced-motion の説明にあるとおり、この設定は、動きのあるアニメーションを減らしたい、という利用者の希望をブラウザに伝えます。Playwright でこの設定にして、上の3通りの描き方で同じように入れ替えると、150ms 後には3通りとも動き終わった位置にあり、棒はすべらずに入れ替わりました。この @media で止めているのは transition だけなので、比べている棒の色と回数の表示は、設定に関係なく1手ごとに変わります。

​Posted by 86