1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「恋みくじ」を作る:crypto.getRandomValues()で偏りの少ない抽選を実装する

1
Posted at

Webブラウザだけで動く、小さな「恋みくじ」を作ります。

単に配列からランダムに1件選ぶだけなら数行で書けますが、この記事では次の点まで考えます。

  • Math.random()に頼らずWeb Crypto APIを使う
  • 剰余(%)による偏りを避ける
  • 結果をtextContentで安全に描画する
  • aria-liveを使い、結果の変化を読み上げ可能にする
  • 同じ日に何度も引いた場合の扱いを決める
  • 結果データと表示処理を分離する

フレームワークや外部ライブラリは使いません。HTML、CSS、JavaScriptの3ファイルで動作します。

▶ 完成するもの

ボタンを押すと、次の情報を持つ結果が1件表示されます。

  • 運勢名
  • 短いメッセージ
  • 今日の行動ヒント

占いコンテンツでは、抽選処理だけでなく「結果をどう読ませるか」も重要です。実際の画面構成や言葉の見せ方を考える際は、公開されているサービスを触ってみると参考になります。たとえば一禅堂の恋みくじ公式サイトでは、スマートフォンから迷わず結果へ進める導線を確認できます。

この記事では特定サービスの内部実装を扱うのではなく、独立したサンプルをゼロから作成します。

▶ ファイル構成

text
love-omikuji/
├── index.html
├── style.css
└── app.js

ローカルで試すだけなら、3ファイルを同じディレクトリへ置き、index.htmlをブラウザで開けば動きます。

▶ 1. HTMLを作る

html:index.html

今日の恋みくじ

TODAY'S LOVE FORTUNE

今日の恋みくじ

心を落ち着けて、今いちばん気になることを一つ思い浮かべてください。

<button id="draw-button" class="draw-button" type="button">
  恋みくじを引く
</button>

<section
  id="result"
  class="result"
  aria-live="polite"
  aria-atomic="true"
  hidden
>
  <p id="fortune-rank" class="fortune-rank"></p>
  <h2 id="fortune-message"></h2>
  <p id="fortune-advice" class="fortune-advice"></p>
</section>

<button id="reset-button" class="reset-button" type="button" hidden>
  結果を閉じる
</button>

結果領域にはaria-live="polite"を設定しました。ボタン操作によって結果が更新されたとき、対応するスクリーンリーダーへ変化を通知できます。

aria-atomic="true"を指定することで、変更された一部分だけでなく、結果領域全体を一つのまとまりとして読み上げてもらいやすくします。

初期状態では結果がないため、hidden属性で非表示にしています。

▶ 2. 最小限のCSSを追加する

css:style.css
:root {
color-scheme: light;
font-family:
system-ui, -apple-system, BlinkMacSystemFont,
"Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
color: #352d31;
background: #fff8fb;
}

  • {
    box-sizing: border-box;
    }

body {
min-height: 100svh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top, #ffe8f1 0, transparent 44%),
#fff8fb;
}

.omikuji {
width: min(100%, 520px);
padding: clamp(24px, 6vw, 48px);
text-align: center;
background: rgb(255 255 255 / 92%);
border: 1px solid #f0ccd9;
border-radius: 24px;
box-shadow: 0 18px 50px rgb(113 61 80 / 12%);
}

.eyebrow {
margin: 0 0 8px;
color: #a34368;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.12em;
}

h1 {
margin: 0;
font-size: clamp(1.8rem, 6vw, 2.6rem);
}

.lead {
margin: 18px auto 28px;
line-height: 1.8;
}

button {
min-height: 48px;
border-radius: 999px;
font: inherit;
font-weight: 700;
cursor: pointer;
}

.draw-button {
width: min(100%, 280px);
padding: 12px 24px;
color: #fff;
background: #a34368;
border: 2px solid #a34368;
}

.draw-button:hover {
background: #873451;
border-color: #873451;
}

.draw-button:focus-visible,
.reset-button:focus-visible {
outline: 3px solid #2b73d2;
outline-offset: 3px;
}

.draw-button:disabled {
cursor: wait;
opacity: 0.65;
}

.result {
margin-top: 32px;
padding: 24px;
background: #fff4f8;
border-radius: 18px;
}

.fortune-rank {
margin: 0 0 10px;
color: #a34368;
font-size: 1.5rem;
font-weight: 800;
}

.result h2 {
margin: 0;
font-size: 1.25rem;
line-height: 1.6;
}

.fortune-advice {
margin: 16px 0 0;
line-height: 1.8;
}

.reset-button {
margin-top: 20px;
padding: 8px 18px;
color: #67384a;
background: transparent;
border: 1px solid #c897aa;
}

@media (prefers-reduced-motion: no-preference) {
.result:not([hidden]) {
animation: reveal 300ms ease-out;
}

@keyframes reveal {
from {
opacity: 0;
transform: translateY(8px);
}
}
}

フォーカスリングを消さず、キーボード操作でも現在位置が分かるようにしています。また、結果表示のアニメーションはprefers-reduced-motionno-preferenceの場合だけ有効にしました。

▶ 3. 結果データを配列で管理する

文章をHTMLへ直接埋め込まず、JavaScriptのオブジェクトとして管理します。

js:app.js
const fortunes = [
{
rank: "大吉",
message: "素直な一言が、二人の距離を近づける日",
advice: "考えすぎる前に、短くても自分の言葉で伝えてみましょう。"
},
{
rank: "中吉",
message: "急がずに信頼を育てるとき",
advice: "結論を求めるより、相手の話を最後まで聞くことを大切に。"
},
{
rank: "小吉",
message: "小さな変化に気づけそう",
advice: "いつもと違う道や予定の中に、会話のきっかけがあります。"
},
{
rank: "末吉",
message: "今日は自分の心を整える日",
advice: "返事を急がず、まずは自分が何を望んでいるか整理しましょう。"
},
{
rank: "注意",
message: "思い込みで相手の気持ちを決めないで",
advice: "不安なときほど事実と想像を分け、落ち着いて確認しましょう。"
}
];

実サービスでは、結果文を別のJSONファイルやCMSで管理する選択肢もあります。データと描画処理を分離しておくと、文言の追加や翻訳がしやすくなります。

▶ 4. crypto.getRandomValues()で整数を選ぶ

最も単純な実装は次のようになります。

js
const index = Math.floor(Math.random() * fortunes.length);

カジュアルな演出なら、多くの場合これでも十分です。ただし今回は、OSが提供する乱数源を利用できるWeb Crypto APIのcrypto.getRandomValues()を使います。

▶# %だけではわずかな偏りが生まれる

次のコードには注意が必要です。

js
const values = new Uint32Array(1);
crypto.getRandomValues(values);
const index = values[0] % fortunes.length;

Uint32Arrayが取り得る値は0から2^32 - 1までです。結果数が2^32を割り切れない場合、余りに変換したとき、一部のインデックスが他より1回多く対応します。

実用上ごく小さな差ですが、汎用関数としては棄却法で避けられます。

js:app.js
function secureRandomInt(maxExclusive) {
const uint32Range = 0x1_0000_0000; // 2^32

if (
!Number.isSafeInteger(maxExclusive) ||
maxExclusive <= 0 ||
maxExclusive > uint32Range
) {
throw new RangeError("maxExclusive must be an integer from 1 to 2^32");
}

const limit = uint32Range - (uint32Range % maxExclusive);
const values = new Uint32Array(1);

do {
crypto.getRandomValues(values);
} while (values[0] >= limit);

return values[0] % maxExclusive;
}

limit以上の値を捨てることで、残った範囲はmaxExclusiveで割り切れるようになります。そのため、各インデックスへ同じ個数の元データが対応します。

なお、これは暗号や抽選販売を実装するための記事ではありません。景品、金銭、権利が関わる厳密な抽選では、クライアント側だけで完結させず、要件に合ったサーバー側の設計、監査ログ、改ざん対策などを別途検討する必要があります。

▶ 5. DOMへ結果を表示する

DOM要素を取得し、クリック時に結果を描画します。

js:app.js
const drawButton = document.querySelector("#draw-button");
const resetButton = document.querySelector("#reset-button");
const result = document.querySelector("#result");
const rank = document.querySelector("#fortune-rank");
const message = document.querySelector("#fortune-message");
const advice = document.querySelector("#fortune-advice");

function renderFortune(fortune) {
rank.textContent = fortune.rank;
message.textContent = fortune.message;
advice.textContent = 今日のヒント:${fortune.advice};

result.hidden = false;
resetButton.hidden = false;
}

function drawFortune() {
const index = secureRandomInt(fortunes.length);
renderFortune(fortunes[index]);
}

drawButton.addEventListener("click", () => {
drawButton.disabled = true;

// 連打による二重実行を避け、演出用に少し待つ
window.setTimeout(() => {
drawFortune();
drawButton.disabled = false;
}, 250);
});

resetButton.addEventListener("click", () => {
result.hidden = true;
resetButton.hidden = true;
drawButton.focus();
});

表示にはinnerHTMLではなくtextContentを使っています。今回の結果はコード内の固定データですが、将来APIやCMSから文章を取得する形に変えた場合も、単純なテキスト表示であればHTMLとして解釈させないほうが安全です。

結果を閉じた後はdrawButton.focus()でフォーカスを戻します。見た目だけでなく、キーボード操作の流れも途切れにくくなります。

▶ 6. 「1日1回」の結果を保存する

何度でも引ける設計も間違いではありません。一方で「今日の結果」という体験にしたいなら、同じ日の結果をlocalStorageへ保存できます。

まず、ローカル日付のキーを作ります。

js:app.js
function getLocalDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return ${year}-${month}-${day};
}

タイムゾーンの影響を避けるため、toISOString()の日付部分は使わず、ブラウザのローカル年月日から生成しています。

保存と復元を追加します。

js:app.js
const storageKey = "love-omikuji:daily-result";

function saveDailyFortune(index) {
const value = {
date: getLocalDateKey(),
index
};

localStorage.setItem(storageKey, JSON.stringify(value));
}

function loadDailyFortune() {
try {
const rawValue = localStorage.getItem(storageKey);
if (!rawValue) return null;

const value = JSON.parse(rawValue);

if (
  value.date !== getLocalDateKey() ||
  !Number.isInteger(value.index) ||
  !fortunes[value.index]
) {
  return null;
}

return value.index;

} catch {
return null;
}
}

最後にdrawFortune()を差し替えます。

js:app.js
function drawFortune() {
const savedIndex = loadDailyFortune();
const index = savedIndex ?? secureRandomInt(fortunes.length);

if (savedIndex === null) {
saveDailyFortune(index);
}

renderFortune(fortunes[index]);
}

これで、同じブラウザでは同じ日に同じ結果が表示されます。

ただしlocalStorageは利用者が削除・変更でき、別端末や別ブラウザとは共有されません。これは不正防止機能ではなく、体験を整えるためのクライアント側保存です。

また、保存前にプライベートブラウズやストレージ制限によって例外が起きる環境まで考慮するなら、setItem()側もtry...catchで囲み、保存できなくても抽選自体は続行する設計にすると堅牢です。

▶ 7. 本番公開前の確認項目

小さなコンテンツでも、公開前に次を確認しておくと安心です。

▶# 表示と操作

  • スマートフォンの幅で横スクロールが発生しないか
  • キーボードだけでボタンを操作できるか
  • フォーカス位置が見えるか
  • 結果の文字と背景に十分なコントラストがあるか
  • アニメーションを減らす設定が尊重されるか

▶# データ

  • 結果文に重複や矛盾がないか
  • 特定の属性や恋愛観を否定する文章になっていないか
  • 結果数を変更しても抽選関数が動くか
  • JSON化する場合、壊れたデータをどう扱うか

▶# セキュリティとプライバシー

  • APIやCMSの文章を無条件にinnerHTMLへ渡していないか
  • 不要な個人情報を入力させていないか
  • アクセス解析を使う場合、必要な説明を行っているか
  • 外部共有機能で利用者の入力内容が意図せず公開されないか

▶# コンテンツとしての説明

  • 結果が将来を保証するものではないと分かるか
  • 大きな決断を占いだけに委ねるよう促していないか
  • 悪い結果が出ても過度な不安を与えない表現になっているか

▶ まとめ

恋みくじのような小さなWebコンテンツにも、フロントエンド開発の基本が詰まっています。

  • 結果データと描画処理を分ける
  • Web Crypto APIから乱数を得る
  • 棄却法で剰余の偏りを避ける
  • textContentで文字列を描画する
  • aria-liveやフォーカス制御を考える
  • localStorageの用途と限界を理解する

見た目はシンプルでも、抽選、状態管理、安全なDOM操作、アクセシビリティを一つずつ考えると、再利用できる実装になります。

次の発展としては、結果データのJSON化、SNS共有用画像の生成、Service Workerによるオフライン対応、結果ごとのURL設計などを追加すると、より実サービスに近い題材として学べます。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?