Web でランダムルーレットを作るとき、円を描いて CSS で回すところまでは難しくありません。実装で本当に注意すべきなのは、選択確率を偏らせず、利用者が結果を確認・説明できる状態にすることです。
授業の指名、抽選会、チーム分けのように結果が重要な用途では、「それっぽく止まる」だけでは不十分です。本記事では GoSpinWheel の実装経験から、ランダム抽選ロジック、重み付き選択、状態管理、入力検証をコード中心に整理します。
結果は回転前に決める
アンチパターンは、CSS アニメーションが終わった角度から当選項目を逆算する実装です。この方法では描画の丸め、フレームレート、アニメーション中断が抽選ロジックに入り込みます。
処理は次の 4 段階に分けます。
- 配列から当選インデックスを選ぶ
- 当選スライスの中央に対応する停止角度を求める
- その角度まで回転させる
- アニメーション完了後に、最初に選んだ値を表示する
抽選関数が DOM や Canvas に依存しなければ、単体テストが簡単です。モーション軽減やアニメーションのスキップにも確率を変えずに対応できます。
crypto.getRandomValues() で一様なインデックスを作る
Math.random() はシミュレーションや演出には便利ですが、信頼性を前面に出す抽選には Web Crypto API を使うほうが適切です。
ただし、次のような実装には剰余バイアスがあります。
// 2^32 が length で割り切れない場合、一部の値がわずかに多くなる
return randomUint32 % length;
上端の余りを捨てる棄却サンプリングで回避できます。
function randomIndex(length) {
if (!Number.isSafeInteger(length) || length <= 0) {
throw new RangeError("length must be a positive safe integer");
}
const range = 2 ** 32;
const limit = range - (range % length);
const value = new Uint32Array(1);
do {
crypto.getRandomValues(value);
} while (value[0] >= limit);
return value[0] % length;
}
[0, limit) の範囲だけを採用すれば、各インデックスに割り当てられる 32 bit 値の個数が同じになります。通常の項目数なら再試行はほぼ発生しません。
GoSpinWheel の通常スピンでは、ブラウザ標準の crypto.getRandomValues() を抽選結果に使っています。アルゴリズムと乱数源を公開情報として説明できることも、ユーザーの安心につながります。
重み付き抽選の実装
各項目に重みを設定する場合、重み 2 は重み 1 の 2 倍の当選確率を意味します。重みの合計を区間として扱うとシンプルに実装できます。
function pickWeighted(entries, randomUnit) {
const total = entries.reduce((sum, entry) => sum + entry.weight, 0);
let target = randomUnit * total;
for (const entry of entries) {
if (target < entry.weight) return entry;
target -= entry.weight;
}
return entries.at(-1);
}
本番コードでは、関数を呼ぶ前に次を検証します。
- 重みが有限値か
- 負数ではないか
- 正の重みが 1 つ以上あるか
- 合計が安全に扱える範囲か
randomUnit を引数にすると、0、境界の直前、1 に近い値などを注入でき、テストしやすくなります。本番では Web Crypto API から偏りのない値を生成します。
また、UI には重みだけでなく entry.weight / total * 100 で求めた確率も表示します。「重み 3」より「当選確率 25%」のほうが利用者に伝わります。
表示状態と抽選対象を別々に持つ
抽選後に当選項目を除外するモードでは、次の状態を混ぜないようにします。
{
label: "Team A",
weight: 1,
hidden: false, // ラベルを見せるか
eligible: true // 抽選対象か
}
hidden はミステリー表示のため、eligible は確率計算のためです。ラベルを隠しただけで抽選対象から外れてしまう実装は、利用者の予想と一致しません。
当選後は結果を表示してから除外し、履歴と Undo / Reset を用意します。内部状態が変化したタイミングを UI に反映することが大切です。
ローカルファーストで個人情報を守る
抽選項目には氏名や社内情報が入ることがあります。入力と同時にサーバーへ送信する必要はありません。
基本機能はブラウザ内で完結させます。
- 匿名編集・抽選:メモリ
- 端末保存:Local Storage など
- バックアップ:明示的な JSON エクスポート
- クラウド保存/公開 URL:利用者が選択した場合だけ送信
GoSpinWheel でも、匿名利用者は編集、抽選、CSV/TSV の読み込み、JSON の書き出し、端末保存をローカルで行えます。通信が必要な機能を分離すると、プライバシーだけでなくレスポンスや障害耐性も改善します。
CSV / TSV を安全に読み込む
インポートデータは信頼せず、最低限次を実装します。
- ファイルサイズと項目数の上限
-
name、option、weight、probabilityなど許可するヘッダー - 反映前のプレビュー
- 空文字、不正な重み、極端に長い文字列の検証
- 数式や HTML を実行しない
GoSpinWheel は現在、300 KB までのテキスト/CSV/TSV、最大 1,100 スライスに制限しています。上限を明示すると、パースや描画に必要なリソースを予測できます。
分布を自動テストする
1 回の結果ではなく、多数回の頻度を検証します。
均等抽選なら、すべての項目が選ばれ、観測回数が期待値から大きく外れていないことを確認します。重み付き抽選なら、観測比率を期待確率と比較します。統計テストだけに頼らず、次の境界条件も個別にテストします。
- 項目数 1
- 重み 0
- 極端な重み差
- 重複ラベル
- 削除と Undo
- 複数ルーレットの同時処理
- reduced motion
まとめ
公平なオンライン抽選は、ホイールの回転表現だけでは作れません。
- 結果とアニメーションを分離する
- Web Crypto API と棄却サンプリングを使う
- 重みから導出した確率を表示する
- 表示、抽選対象、履歴を別々に管理する
- 個人データを初期状態では端末内に置く
- インポートを検証し、分布をテストする
この設計なら、画面効果を外してもルールが変わらず、利用者に仕組みを説明できるランダム抽選ツールになります。
この記事は GoSpinWheel の開発知見をもとに、DEV Community の英語版を Qiita 読者向けに再構成しました。