JavaScript:キャンバスで普通のアナログ時計を作る

JavaScript

JavaScriptキャンバスのrotate命令を効果的に使ってアナログ時計を作る上での考え方とコードを紹介します。

この記事で紹介しているアナログ時計は、ブラウザを最大化して表示するとディスプレイがそのまま時計になります。

実行イメージ

公開サイト

キャンバスでアナログ時計を作るときの考え方

まずは正方形のcanvasを考えてください。
例として500×500ピクセルのcanvasを定義したとします。

<canvas id="clock" width="500" height="500"></canvas>

canvasの座標系はHTMLの座標系同様に左上がx, y = (0, 0)となります。

時計を描画する場合、文字盤や針は時計の中心を基準に配置されることになります。
左上が原点となる座標系と時計の描画では少々相性が悪いです。

原点をどうするか?

時計の中心を描きたい位置をx, y = (0, 0) として、キャンバスの原点となるようにしたいところです。

canvasにはtranslateという原点を変更できる便利な命令があります。
例えば以下のようにすると500×500ピクセルのキャンバスなら原点をキャンバス中心にできます。

context.translate(250, 250);

translateは、アナログ時計描画時に1回実行しておけば大丈夫です。

CanvasRenderingContext2D: translate() method - Web APIs | MDN
The CanvasRenderingContext2D.translate() method of the Canvas 2D API adds a translation transformati...

時計の枠の描画

キャンバスの中心を原点として (0,0)にすれば、時計の描画をしやすくなります。

時計の枠を描画するなら以下のように描画する円弧の中心座標を(0, 0)としてarcを使います。

g.beginPath();
g.arc(0, 0, 200, 0, Math.PI*2, true);
g.fill();
実際は塗りつぶし色を指定して白と緑の円弧をそれぞれ描画しています

時計の針の描画

問題となるのは、時計の針です。
時針、分針、秒針の3種類ありますが、一番分かりやすい秒針で説明します。

秒針は60秒で1周します。
1秒ごとの原点からの角度は

360 ÷ 60 = 6度

です。
12時の方向を0度とおくと、原点からの角度は0秒が0度で5秒なら30度ということになります。

ここで問題があります。
canvasでは、向かって右方向が0度という扱いになっている点です。

12時方向を0度とするには、-90度回転する必要があります。(時計回りがプラスなので-90度となります)
ここでrotateの出番です。

context.rotate(回転させたい角度をラジアンで指定);
CanvasRenderingContext2D: rotate() method - Web APIs | MDN
The CanvasRenderingContext2D.rotate() method of the Canvas 2D API adds a rotation to the transformat...

残念ながら-90度回転させたい時、以下のようには出来ません。

context.rotate(-90); ×これは出来ない

度数をラジアンに直す式は

ラジアン = 度数 × 円周率 ÷ 180

円周率は、JavaScriptではMath.PIで表されるので、上記式にあてはめてソースコードに直すと

-90 * Math.PI /180

となり、-Math.PI/2 が得られます。
これはrotateの引数に指定して、

context.rotate(-Math.PI/2);

として12時方向を0度にします。
この状態が基本となります。

秒針の描画

例として5秒の場合だと

5秒 × 6度 = 30度

基本状態から30度の方向に秒針を描画すれば…と言いたいところですが、考え方は違います。
12時方向を0度とした上で、更にrotateを使います。

context.rotate(秒数 * Math.PI/30);

現在の秒数分×6度(ただしラジアン単位のため Math.PI/30)をrotateの引数に設定します。
例えば現在5秒なら

context.rotate(5 * Math.PI/30);

とします。

実際の座標イメージはこうなります。

5秒の状態を0度という扱いにしてしまうのです。

こうすることで描画する際、y座標は常に0とおくことができ、x座標は秒針に必要な長さ分でこれも固定で描画できます。要するにxy座標を計算しないで描画のコードを固定できるという事です。

秒針を描画するコードイメージ(常に同じコードで描画することになる)

g.beginPath();
g.moveTo(-30, 0);
g.lineTo(105, 0);
g.stroke();

分針時針も考え方は同じです。

現在時刻より針の角度を計算してrotate命令で計算した角度を0度扱いとし、描画する際は同じ命令で描画する。
これがrotateを使ったアナログ時計描画のポイントです。

ソースコード

index.html

<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width,user-scalable=yes">
		<script src="analog_clock.js" type="text/javascript"></script>
		<title>アナログ時計</title>
		<!--
			普通のアナログ時計です v1.0
			
					created at 2021-10-17 dianxnao.com
		-->
		<style>
			*{
				margin: 0;
				padding: 0;
			}
			#clock{
				display: block;
			}
			html, body, #wrapper{
				width: 100%;
				height: 100%;
			}
		</style>
	</head>
	<body>
		<div id="wrapper">
			<canvas id="clock" width="" height=""></canvas>
		</div>
	</body>
</html>

analog_clock.js

/* ---------------------------------------------------------------------
	アナログ時計
 --------------------------------------------------------------------- */
/*
 * グローバル変数
 */
let wrapper = null;				// キャンバスの親要素
let canvas = null;					// キャンバス
let g = null;						// コンテキスト
let clock_size;					// 時計のサイズ
let scale;							// 時計のスケール
let center = { x:0, y:0 };			// 時計の中心座標
let $id = function(id){ return document.getElementById(id); };	// DOM取得用

/*
 * 定数
 */
const BACKGROUND_COLOR = "black";		// 背景色
const WAKU_COLOR = "yellowgreen";		// 時計枠の色
const CLOCK_BG_COLOR = "white";			// 時計枠内側の色
const CLOCK_CENTER_COLOR = "deeppink";	// 時計針の中心のピンの色
const MOJI_BAN_COLOR = "black";			// 文字盤の12本の線の色
const SUJI_COLOR = "gray"				// 数字の色
const JI_SHIN_COLOR = "black";			// 時針の色
const FUN_SHIN_COLOR = "black";			// 分針の色
const BYOU_SHIN_COLOR = "deeppink";		// 秒針の色

/*
 * キャンバスのサイズをウインドウに合わせて変更
 */
function getSize(){
	// キャンバスのサイズを再設定
	canvas.width = wrapper.offsetWidth;
	canvas.height =  wrapper.offsetHeight;
	// キャンバスの中心を設定
	center.x = canvas.width / 2;
	center.y = canvas.height / 2;
	// 短辺を時計のサイズとする
	clock_size = canvas.width >= canvas.height ? canvas.height : canvas.width;
	// 時計の縮尺を設定(ウインドウ短辺=500px のとき 縮尺=1 とする)
	scale = clock_size / 500.0;
}

/*
 * リサイズ時(キャンバスの中心と時計の縮尺を再設定)
 */
window.addEventListener("resize", function(){
	getSize();
});

/*
 * アナログ時計を描画する
 */
function clock(){
	g.save();		// デフォルト設定保存
	
	// 背景色を描画
	g.fillStyle = BACKGROUND_COLOR;
	g.fillRect(0, 0, canvas.width, canvas.height);
	
	// 時計枠の内側(背景色)を描画
	g.translate(center.x, center.y);		// キャンバスの中心を中心座標に設定
	g.scale(scale, scale);				// 時計の縮尺を設定
	g.fillStyle = CLOCK_BG_COLOR;
	g.beginPath();
	g.arc(0, 0, 200, 0, Math.PI*2, true);
	g.fill();

	// 時計枠の描画
	g.beginPath();
	g.lineWidth = 25;
	g.strokeStyle = WAKU_COLOR;
	g.arc(0, 0, 200, 0, Math.PI*2, true);
	g.stroke();

	g.rotate(-Math.PI/2);	// 左に90度回転(12時方向を0度とするため)
	g.lineCap = "round";	// 時針、分針、秒針の角をを丸くするため設定
	
	// 現在時刻取得
	let now = new Date();
	
	let hour = now.getHours();			// 時
	let minute = now.getMinutes();		// 分
	let second = now.getSeconds();		// 秒
	
	hour = hour >= 12 ? hour - 12 : hour;	// 13時~24時  -> 1時~12時に変更
	
	// 文字盤の時間を表す12本の線を描画
	g.save();
	g.strokeStyle = MOJI_BAN_COLOR;
	g.lineWidth = 4;

	g.beginPath();
	for(let i=0; i<12; i++){
		g.rotate(Math.PI/6);	// 30度ずつ回転
		g.moveTo(170, 0);
		g.lineTo(180, 0);
	}
	g.stroke();
	g.restore();

	// 時間の数字を描画
	g.save();
	g.rotate(Math.PI/2);		// 回転を元に戻す(3時15分方向を0度)
							// 文字盤の数字の描画向きが傾いてしまう為
	g.fillStyle = SUJI_COLOR;
	g.font = "bold 32px sans-serif";	// ゴシック体
	g.textBaseline = "middle";
	
	let angle = -60;	// 時計中心からの角度(数字の1から描画開始)
	let offset = [10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 15, 20];	// 数字によるx座標のずれを補正
	let r = 150;		// 時計中心からの半径

	i = 1;
	while(i <= 12){
		let radian = angle * Math.PI / 180;	// ラジアンに変換
		let x = r * Math.cos(radian);		// x座標
		let y = r * Math.sin(radian);		// y座標

		if(i % 3 == 0) g.fillText(i, x-offset[i-1], y);	// 3, 6, 9, 12のみ描画
		angle += 30;
		i++;
	}
	g.restore();

	// 時針を描画
	g.save();
	g.rotate( hour * (Math.PI/6) + minute * (Math.PI/360) + second * (Math.PI/21600) );	// 時数*30度 + 分数*0.5度 + 秒数 * 0.00833333333度 回転
	g.lineWidth = 12;
	g.strokeStyle = JI_SHIN_COLOR;
	g.beginPath();
	g.moveTo(-20, 0);
	g.lineTo(85, 0);
	g.stroke();
	g.restore();
	
	// 分針を描画
	g.save();
	g.rotate( minute * (Math.PI/30) + second * (Math.PI/1800) );	// 分数*6度 + 秒数*0.1度 回転 
	g.lineWidth = 8;
	g.strokeStyle = FUN_SHIN_COLOR;
	g.beginPath();
	g.moveTo(-28, 0);
	g.lineTo(112, 0);
	g.stroke();
	g.restore();
	
	// 秒針を描画
	g.rotate(second * Math.PI/30);	// 秒数*6度 回転
	g.strokeStyle = BYOU_SHIN_COLOR;
	g.lineWidth = 4;
	g.beginPath();
	g.moveTo(-30, 0);
	g.lineTo(105, 0);
	g.stroke();

	// 時計の中心を描画
	g.fillStyle = CLOCK_CENTER_COLOR;
	g.beginPath();
	g.arc(0, 0, 8, 0, Math.PI*2, true);
	g.fill();

	g.restore();

	setTimeout(clock, 1000);	// 再帰呼び出し
}

/*
 * 起動時の処理
 */
window.addEventListener("load", function(){
	// キャンバスの親要素情報取得(親要素が無いとキャンバスのサイズが画面いっぱいに表示できないため)
	wrapper = $id("wrapper");
	// キャンバス情報取得
	canvas = $id("clock");
	g = canvas.getContext("2d");

	// キャンバスをウインドウサイズにする
	getSize();
	
	// アナログ時計を起動
	clock();
});

ソースコードの補足解説

CSS部分は、index.htmlに含めてあります。
CSS部分では、canvasタグをブロック要素として常に端末の中央に位置するようにしています。

*{
	margin: 0;
	padding: 0;
}
#clock{
	display: block;
}
html, body, #wrapper{
	width: 100%;
	height: 100%;
}

関数getSizeで、端末サイズに合わせてキャンバスサイズを変更しています。
基本的には端末の短辺キャンバスサイズを合わせることで時計が端末サイズに収まるようにしています。

/*
 * キャンバスのサイズをウインドウに合わせて変更
 */
function getSize(){
	// キャンバスのサイズを再設定
	canvas.width = wrapper.offsetWidth;
	canvas.height =  wrapper.offsetHeight;
	// キャンバスの中心を設定
	center.x = canvas.width / 2;
	center.y = canvas.height / 2;
	// 短辺を時計のサイズとする
	clock_size = canvas.width >= canvas.height ? canvas.height : canvas.width;
	// 時計の縮尺を設定(ウインドウ長辺=500px のとき 縮尺=1 とする)
	scale = clock_size / 500.0;
}

端末サイズが変わってキャンバスが大きくなったり小さくなったりしますが、その際描画コードをいちいち再計算しなくていいようにしたいと思いました。

最初の時計イメージは、キャンバスサイズを500×500ピクセルの正方形として作りました。
その際の描画コードをそのまま使うため、500ピクセルを縮尺1としています。

関数getSize内の変数scale

scale = clock_size / 500.0;

キャンバスには、scale命令というものがあります。
引数には、水平方向(x方向)・垂直方向(y方向)の縮尺を設定できます。

context.scale(x方向の拡縮, y方向の拡縮);
CanvasRenderingContext2D.scale() - Web API | MDN
CanvasRenderingContext2D.scale() はキャンバス 2D API のメソッドで、キャンバス上の長さを縦方向および横方向に拡縮する変形を適用させます。

例えば、元は500×500ピクセルのキャンバスに描画していたものを1000×1000ピクセルの2倍サイズのキャンバスに見た目を同じように描画したいなら

context.scale(2.0, 2.0);

とすれば、描画のmoveTo, lineTo, arcなどの命令に指定した引数の数値はそのままも大丈夫ということです。(scale命令は、時計を描画する関数clockの先頭の方で実際に使っています)

文字盤の数値は、キャンバスのfillTextで描画しますが、rotateを実行して12時方向を0度にしたまま文字を描画すると文字が90度傾いてしまいます。
線や円弧の描画では問題にはなりませんが、文字描画だとこれは変です。

rotateを使うと文字が傾いてしまう

文字が傾かないように以下のように90度時計方向に戻してから文字の描画(fillText)を実行するようにしています。

context.rotate(Math.PI/2)

ここまでの解説は分かりにくかったかもしれません。
ソースコードを色々と変更して理解してみてください。

実はわたしが最初に作ったバージョンは文字盤の方が動いてしまう非実用的な変なアナログ時計でした。
これはこれで面白いので、興味がある方はご覧ください。(わたしの運営する別サイトになります)

JavaScript:canvasで秒針以外が動く変なアナログ時計を作る
秒針以外が動いて回転する変なアナログ時計です。時にはこういった視点も必要かと思い作りました。

以上、JavaScript:キャンバスで普通のアナログ時計を作るでした。

コメント

タイトルとURLをコピーしました