はじめに (対象読者・この記事でわかること)
この記事は、JavaScriptの基本的な構文を理解しており、ウェブページに動的な要素を追加することに興味がある方を対象としています。特に、HTMLのCanvas要素を使って図形を描画したり、時間と同期したアニメーションを作成したりする方法を学びたい方に最適です。
この記事を読むことで、以下のことがわかるようになります。 * JavaScriptのCanvas APIを使って、リアルタイムでアナログ時計の針を描画する方法。 * 時間の概念を角度に変換し、時計の針を正確に動かすロジック。 * 2つの針(時間針と目安針)の角度を比較し、特定条件下でアラームを鳴らす衝突判定ロジック。 * 簡単なアラーム音を再生する方法と、連続再生を防ぐためのフラグ管理。
プログラミングを通して、視覚的に分かりやすいアプリケーションを構築する楽しさを感じていただければ幸いです。
前提知識
この記事を読み進める上で、以下の知識があるとスムーズです。
* HTML/CSSの基本的な知識(<div> や <canvas> 要素の配置、スタイリング)
* JavaScriptの基本的な構文(変数、関数、条件分岐、ループ、DOM操作)
* 数学の基本的な三角関数(sin, cos, atan2)の概念(深く理解していなくてもコードは追えますが、計算の意図が掴みやすくなります)
JavaScriptとCanvasでアナログ時計を再現する魅力と課題
ウェブ上で時計を表示する方法はたくさんありますが、アナログ時計をゼロから描画し、それを動かすのはJavaScriptのCanvas APIの強力な機能を示す良い例です。Canvas APIはHTML5で導入された要素で、JavaScriptを使ってビットマップグラフィックスを動的に描画するためのインターフェースを提供します。これにより、単なる静的な画像では表現できない、リアルタイムなアニメーションやインタラクティブな要素を作成することが可能になります。
今回のテーマである「アナログの目覚まし時計」は、針の動きを正確にシミュレートし、さらに「目安針に時間針が接触したらアラームが鳴る」というロジックを実装することで、より実践的なWebアプリケーション開発のスキルを習得できます。ここでいう「接触」とは、時間針と目安針の角度が一定の許容範囲内になった状態を指します。アナログ時計の針は円周上を動くため、その動きを角度で捉え、現在の時刻に応じた正確な角度を計算することが重要になります。また、アラーム機能を実装する際には、ユーザーの操作なしに音を再生する場合のブラウザの制限や、一度アラームが鳴ったらリセットされるまで再発火しないようにする状態管理なども考慮する必要があります。
CanvasとJavaScriptでアナログ時計とアラーム機能を実装する
ここでは、HTMLの構造からJavaScriptでの描画ロジック、そして肝となる針の衝突判定とアラーム機能の実装までを順を追って解説します。
ステップ1: HTMLとCSSの準備
まず、アナログ時計を表示するためのHTMLファイルを用意し、Canvas要素を配置します。簡単なCSSでCanvasをセンタリングし、背景色などを設定しておきましょう。
index.html
Html<!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>アナログ目覚まし時計</title> <link rel="stylesheet" href="style.css"> </head> <body> <div class="container"> <h1>アナログ目覚まし時計</h1> <canvas id="alarmClockCanvas" width="400" height="400"></canvas> <button id="resetAlarmButton">アラームリセット</button> <p id="alarmStatus">アラームはOFFです</p> </div> <script src="script.js"></script> </body> </html>
style.css
Cssbody { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background-color: #f0f0f0; font-family: sans-serif; flex-direction: column; } .container { text-align: center; background-color: #fff; padding: 20px; border-radius: 10px; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } canvas { border: 5px solid #333; border-radius: 50%; background-color: #eee; margin-top: 20px; } button { padding: 10px 20px; margin-top: 20px; background-color: #007bff; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; } button:hover { background-color: #0056b3; } #alarmStatus { margin-top: 10px; font-weight: bold; color: #555; }
ステップ2: JavaScriptで時計の描画
次に、JavaScriptを使ってCanvasに時計の文字盤と針を描画し、時間をリアルタイムに反映させて動かします。requestAnimationFrame を使うことで、ブラウザの描画サイクルに合わせてスムーズなアニメーションを実現します。
script.js
Javascriptconst canvas = document.getElementById('alarmClockCanvas'); const ctx = canvas.getContext('2d'); const resetButton = document.getElementById('resetAlarmButton'); const alarmStatusElem = document.getElementById('alarmStatus'); const centerX = canvas.width / 2; const centerY = canvas.height / 2; const radius = canvas.width / 2 - 10; // 枠との余白 let alarmHour = 9; // 目安針の時 (例: 9時) let alarmMinute = 30; // 目安針の分 (例: 30分) let isAlarmTriggered = false; // アラームが発動中かどうかのフラグ let alarmSound = new Audio('alarm.mp3'); // アラーム音ファイルへのパス (適宜変更してください) alarmSound.loop = false; // 一度鳴ったら止まるように // アラーム音ファイルの準備: // 例として、alarm.mp3 という名前でプロジェクトのルートディレクトリに音声ファイルを置いてください。 // 無ければ、簡単な音声ファイルを生成するか、別のものを指定してください。 // 例: https://freesound.org/people/NoiseCollector/sounds/4387/ からダウンロードして 'alarm.mp3' にリネームなど。 // アラームリセットボタンのイベントリスナー resetButton.addEventListener('click', () => { isAlarmTriggered = false; alarmSound.pause(); alarmSound.currentTime = 0; // 再生位置を最初に戻す alarmStatusElem.textContent = 'アラームはOFFです'; console.log("アラームリセット"); }); function drawClock() { // Canvasをクリア ctx.clearRect(0, 0, canvas.width, canvas.height); // 円を描画 (文字盤の背景) ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.strokeStyle = '#333'; ctx.lineWidth = 8; ctx.stroke(); // 時刻の目盛りを描画 ctx.lineWidth = 2; for (let i = 0; i < 60; i++) { const angle = (Math.PI / 30) * i; // 1分(度) = 6度、ラジアンで計算 const length = (i % 5 === 0) ? radius * 0.1 : radius * 0.05; // 5分ごとは長くする const startX = centerX + Math.cos(angle) * (radius - length); const startY = centerY + Math.sin(angle) * (radius - length); const endX = centerX + Math.cos(angle) * radius; const endY = centerY + Math.sin(angle) * radius; ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(endX, endY); ctx.stroke(); } // 中央の点 ctx.beginPath(); ctx.arc(centerX, centerY, 5, 0, Math.PI * 2); ctx.fillStyle = '#333'; ctx.fill(); // 現在時刻を取得 const now = new Date(); const sec = now.getSeconds(); const min = now.getMinutes(); const hr = now.getHours(); // 針の描画 (秒針) drawHand(sec, 'second', radius * 0.8, 2, 'red'); // 針の描画 (分針) drawHand(min, 'minute', radius * 0.7, 4, '#555'); // 針の描画 (時針) // 時針は12時間制なので、時間の値は12で割る。分も考慮に入れる。 drawHand(hr % 12 + min / 60, 'hour', radius * 0.5, 6, '#555'); // 目安針の描画 (点線で表示) drawAlarmHand(alarmHour, alarmMinute, radius * 0.6, 3, 'blue'); // アラーム判定 checkAlarm(hr, min, sec); // 次のフレームを要求 requestAnimationFrame(drawClock); } /** * 針を描画する関数 * @param {number} value - 時刻の値 (秒、分、時) * @param {string} type - 針の種類 ('second', 'minute', 'hour') * @param {number} length - 針の長さ * @param {number} width - 針の太さ * @param {string} color - 針の色 */ function drawHand(value, type, length, width, color) { let angle; ctx.save(); // 現在のCanvasの状態を保存 // Canvasの原点を中心に移動 ctx.translate(centerX, centerY); // 時計回りに回転させるため、Math.PI / 2 (90度) を引いて上を12時の位置とする // 針の角度を計算し、ラジアンに変換 if (type === 'second' || type === 'minute') { angle = (Math.PI / 30) * value - Math.PI / 2; // 60単位で1周 (360度) } else if (type === 'hour') { angle = (Math.PI / 6) * value - Math.PI / 2; // 12単位で1周 (360度) } ctx.rotate(angle); // 計算した角度だけ回転 ctx.beginPath(); ctx.moveTo(0, 0); // 中心から ctx.lineTo(length, 0); // 針の長さ分描画 (x軸方向) ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; // 針の端を丸くする ctx.stroke(); ctx.restore(); // 保存したCanvasの状態に戻す } /** * 目安針を描画する関数 * @param {number} hr - 目安時間 (時) * @param {number} min - 目安時間 (分) * @param {number} length - 針の長さ * @param {number} width - 針の太さ * @param {string} color - 針の色 */ function drawAlarmHand(hr, min, length, width, color) { ctx.save(); ctx.translate(centerX, centerY); // 目安針の角度を計算 (時針と同じロジック) const totalMinutes = hr * 60 + min; // 12時間で1周 (720分で1周) なので、1分あたり (2 * PI) / 720 ラジアン const angle = (Math.PI / 360) * totalMinutes - Math.PI / 2; ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(length, 0); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.setLineDash([5, 5]); // 点線にする ctx.lineCap = 'round'; ctx.stroke(); ctx.setLineDash([]); // 点線設定をリセット ctx.restore(); } /** * アラームが鳴るべきか判定する関数 * @param {number} currentHr - 現在の時 * @param {number} currentMin - 現在の分 * @param {number} currentSec - 現在の秒 */ function checkAlarm(currentHr, currentMin, currentSec) { // 時針の角度を計算 (目安針と比較しやすいように12時間制で統一) const currentHourAngle = ((currentHr % 12) + currentMin / 60) * (Math.PI / 6); // 0-11.99 -> 0-2PI // 目安針の角度を計算 (時針と同じ基準) const alarmHourAngle = ((alarmHour % 12) + alarmMinute / 60) * (Math.PI / 6); // 0-11.99 -> 0-2PI // 角度の差を計算し、絶対値をとる let angleDiff = Math.abs(currentHourAngle - alarmHourAngle); // 角度差が180度を超える場合(例:0度と350度の場合、差は10度とみなす) if (angleDiff > Math.PI) { angleDiff = 2 * Math.PI - angleDiff; } // 閾値を設定 (例: 1分程度の差、ラジアンで表現) // 1分 = 6度 = Math.PI / 30 ラジアン const threshold = Math.PI / 60; // +/- 3度 (0.5分) 程度の許容範囲 if (angleDiff < threshold && !isAlarmTriggered) { // 現在の秒数が0-2秒の間に限定してアラームを発動(毎秒鳴るのを防ぐため) // 厳密には一度鳴ったらフラグを立てて、リセットされるまで鳴らさないようにするのが良い if (currentSec >= 0 && currentSec <= 2) { // 毎分0〜2秒の間に判定 alarmSound.play().catch(error => { console.error("アラーム音の再生に失敗しました:", error); alarmStatusElem.textContent = 'アラーム音再生失敗: ユーザー操作が必要です'; }); isAlarmTriggered = true; alarmStatusElem.textContent = `アラーム発動! ${alarmHour.toString().padStart(2, '0')}:${alarmMinute.toString().padStart(2, '0')} です!`; console.log(`アラーム発動! 現在時刻: ${currentHr}:${currentMin}:${currentSec}`); } } } // 最初の描画呼び出し drawClock();
ハマった点やエラー解決
アナログ時計の実装、特に針の動きとアラーム判定では、いくつかのポイントで躓く可能性があります。
-
針の角度計算と原点の扱いの難しさ:
- 問題: Canvasの
rotate()メソッドは、デフォルトでCanvasの左上隅(0,0)を基準に回転します。しかし、時計の針は中心を基準に回転させたい。また、時計の12時の位置は通常上ですが、三角関数(Math.sin,Math.cos)はX軸の正の方向(右)を0度(0ラジアン)とします。 - 解決策:
ctx.translate(centerX, centerY);を使ってCanvasの原点を時計の中心に移動させます。これにより、rotate()が中心を基準に行われるようになります。- 針の初期位置(12時の方向)を上にするためには、角度計算から
Math.PI / 2(90度) を引く必要があります。これにより、X軸の正の方向から反時計回りに角度が計算されるのが、時計の12時の方向から時計回りに回転するようになります。 - 時針の計算では、
hr % 12とmin / 60を加算することで、分針の動きに応じて時針も滑らかに動くようにします。
- 問題: Canvasの
-
requestAnimationFrameの挙動とリソース管理:- 問題:
setIntervalと同様にrequestAnimationFrameを使ってループさせると、ブラウザのタブが非アクティブになった場合でも処理が走り続けてしまい、不要なCPUリソースを消費する可能性があります。 - 解決策:
requestAnimationFrameはブラウザが最適なタイミングで描画を実行するため、基本的には効率的です。しかし、タブが非アクティブになると自動的に一時停止しますが、厳密には停止しないブラウザもあります。このアプリケーションでは問題になりにくいですが、ゲームなどで一時停止が必要な場合は、visibilitychangeイベントなどを利用してアニメーションを停止・再開するロジックを実装することを検討します。- 今回は、継続的に描画を要求することで常に最新の時刻を反映させています。
- 問題:
-
アラーム音の自動再生制限:
- 問題: 多くのモダンブラウザでは、ユーザーの明示的な操作なしにJavaScriptから音声を自動再生することを制限しています。これにより、ページを開いた瞬間にアラームが鳴らない可能性があります。
- 解決策:
- 今回はアラーム音を
Audioオブジェクトで扱っていますが、alarmSound.play()が失敗する可能性があるため、catchブロックでエラーを捕捉し、ユーザーにその旨を伝えるメッセージを表示するようにしました。ユーザーが何らかの操作(ボタンクリックなど)を行った後であれば再生可能になることが多いです。今回は「アラームリセット」ボタンがその役割を兼ねています。
- 今回はアラーム音を
-
アラームが毎秒鳴ってしまう:
- 問題: 衝突判定が単純に角度の一致だけで行われると、時間針が目安針の範囲内にある間は毎秒アラームが発動し続けてしまいます。
- 解決策:
isAlarmTriggeredというフラグを導入しました。アラームが一度鳴ったらこのフラグをtrueに設定し、trueの間はアラームが再発動しないようにします。- アラームを止めたり、次のアラームをセットしたりする際には、このフラグを
falseにリセットする必要があります。今回の例では「アラームリセット」ボタンがその役割を担っています。 - また、秒数条件 (
currentSec >= 0 && currentSec <= 2) を加えることで、毎分特定のタイミング(例: 0秒〜2秒)でのみアラーム判定を行うようにしています。これにより、同じ分の中で複数回アラームが発動するのを防ぎ、かつ判定の負荷を軽減できます。
解決策
上記の「ハマった点」に対する具体的な解決策は、提示した script.js コード内に実装されています。
- 針の描画:
drawHandおよびdrawAlarmHand関数内でctx.save(),ctx.translate(centerX, centerY),ctx.rotate(angle),ctx.restore()を適切に組み合わせることで、Canvasの中心を基準に針を回転させています。また、Math.PI / 2を引くことで針の向きを調整しています。 - アラーム音の再生:
alarmSound.play().catch(error => { ... });のように.catch()を使い、再生エラーが発生した場合のハンドリングを追加しています。ユーザー操作による再生を促すメッセージ表示も行っています。 - アラームの連続発動防止:
isAlarmTriggeredフラグをグローバルに管理し、アラームが一度鳴ったらtrueに設定することで、同じアラーム設定で繰り返し鳴るのを防ぎます。リセットボタンが押されたときにfalseに戻し、次のアラームに備えます。また、currentSecによる判定タイミングの絞り込みも行っています。
これらの実装により、ユーザーにとってより使いやすく、開発者にとって管理しやすいアナログ目覚まし時計が実現します。
まとめ
本記事では、JavaScriptのCanvas APIを用いてアナログ目覚まし時計を実装し、時間針と目安針の接触を判定してアラームを鳴らす方法 を解説しました。
- Canvas描画の基本:
arc,lineTo,stroke,fillなどの基本的なCanvasメソッドと、requestAnimationFrameを使ったアニメーションループの構築方法を学びました。 - 針の正確な動き: 現在の時刻を正確に角度に変換し、
ctx.translateとctx.rotateを組み合わせて針を時計の中心から回転させるテクニックを習得しました。 - 衝突判定とアラーム機能: 時間針と目安針の角度差を計算し、許容範囲内であればアラームを鳴らすロジックを実装しました。また、
Audioオブジェクトでの音再生と、isAlarmTriggeredフラグを使ったアラームの連続発動防止策についても触れました。
この記事を通して、JavaScriptで視覚的に魅力的なインタラクティブな要素を作成する面白さと、それらを実装する上での実用的な問題解決スキルを実感していただけたかと思います。
今後は、目安針の位置をドラッグで変更できるようにする、複数のアラームを設定できるようにする、アラーム音の種類を選べるようにするなど、UIの改善や発展的な機能追加についても記事にする予定です。
参考資料
- HTML Canvas API - Web API | MDN
- Window.requestAnimationFrame() - Web API | MDN
- Using audio and video - Web media technologies | MDN