【JavaScript】 Canvasに描画してアナログ時計をつくろう!
更新日:2023/01/30
別の記事で、アナログ時計をdiv要素のみで作成したので、今度はCanvasを使ってみます。
キャンバスにアナログ時計を描画
キャンバスでアニメーションをさせる場合、描画したものを消去してから、再度描画します。
少し非効率かな?と感じる人は、レイヤーを使用しての管理も紹介しているので、最後まで読んでみてください。
早速アナログ時計をつくってみます。
Canvasを作成してサイズ位置補正
今回は、div要素内に動的に正方形のcanvasを追加して、次のようなイメージで中心にcanvasを設置してみます。
html
<div id="analog">
</div>
css
#analog{
height: 400px;
max-height: 90vh;
position: relative;
width: 100%;
}
JavaScript:Canvasを作成してサイズ位置補正
/*
* キャンバスの作成・初期化
*/
const initialCanvas = id => {
// div要素を取得
const viewElm = document.getElementById(id);
// div要素の縦横短い方を取得
const minSide = Math.min( viewElm.clientWidth , viewElm.clientHeight );
// 時計の半径を取得
const hankei = minSide / 2;
// キャンバスを作成
const cvs = document.createElement("canvas");
// キャンバスの描画コンテキストを取得
const context = cvs.getContext('2d');
// キャンバスの描画サイズセット
cvs.setAttribute( "width" , minSide );
cvs.setAttribute( "height" , minSide );
// キャンバスの表示サイズセット
const style = cvs.style;
style.width = minSide + "px";
style.height = minSide + "px";
// キャンバスをdiv要素の中央にセット
style.top = ( viewElm.clientHeight - minSide ) /2 + "px" ;
style.left =( viewElm.clientWidth - minSide ) /2 + "px" ;
// キャンバスにスタイルをセット
[ style.position , style.boxSizing , style.border ]=
[ "absolute" , "border-box" ,"0" ];
style.padding = style.margin = "0 0 0 0";
// 描画の原点をキャンバスの中心にセット
context.translate( hankei , hankei );
viewElm.appendChild( cvs );
return { hankei:hankei , context : context };
};
キャンバスは描画エリアと表示エリアの二つを持っています。
それぞれ同じサイズにしないと、拡大・縮小されてしまうので、幅と高さに同じ値をセットしています。
参考記事:【JavaScript】 Canvasのサイズが難解で困る
中央の円を描く
次は描画に移ります。
手始めに一番簡単な、アナログ時計の中央の円を描いてみます。
JavaScript:中央に円を描く
/*
* 真ん中の円の描画データ
*/
const centerCircleData = {
hankei:10, // 円の半径
width:4, // 線の太さ
lineColor: "black", // 線色
fillColor: "silver", // 塗りつぶし色
};
};
/*
* 真ん中に円を描く関数
*/
const centerCircle = ( context ) =>{
const ctx = context;
return () =>{
ctx.lineWidth = 0;
// 半径:centerCircleData.hankei の円を書く
ctx.beginPath();
ctx.fillStyle = centerCircleData.lineColor;
ctx.arc( 0 , 0 , centerCircleData.hankei , 0 , Math.PI * 2 );
ctx.fill();
// 半径:centerCircleData.hankei - centerCircleData.width の円を書く
ctx.beginPath();
ctx.fillStyle = centerCircleData.fillColor;
ctx.arc( 0 , 0 , centerCircleData.hankei - centerCircleData.width , 0 , Math.PI * 2 );
ctx.fill();
};
};
元となるデータ(centerCircleData )に、線の色(lineColor)と塗りつぶしの色(fillColor)をセットしていますが、Canvasのメソッドに枠線を書いた後塗りつぶす機能がありません。
そのため、lineColorで塗りつぶした円を書いた後、fillColorで塗りつぶした円を重ねて書いています。
上のコードを動かしてみます。
JavaScript:テスト
window.addEventListener("DOMContentLoaded", () => {
const { context , hankei } = initialCanvas("analog");
const cCircle = centerCircle( context );
cCircle( );
});
円が描けました。
文字盤を描く
次に文字盤を描画します。
JavaScript:文字盤を描く
/*
* 文字盤描画データ
*/
const mojibanInfo ={
borderWidth : 3, // 外周の円の太さ
borderColor: "#000", // 外周の色
line1:{ // 太い目盛り
width: 5, // 線幅
height: 10 // 線の長さ
},
line2:{ // 細い目盛り
width: 3, // 線幅
height: 5 // 線の長さ
},
text:{ // 数字
dist:30, // 数字の中心の外周からの距離
color:"#000", // 数字の色
font:"bold 1.5em sans-serif" // 数字のフォント
}
};
/*
* 文字盤の描画
*/
const mojiban = ( context , hankei ) =>{
const ctx = context;
// パスを記憶
const memoriPath = ( hankei , type ) => {
const linePath = new Path2D();
linePath.lineWidth = type.width;
linePath.moveTo( 0 , hankei );
linePath.lineTo( 0 , hankei - type.height );
return linePath;
};
return ( ) => {
// 外側の円
ctx.beginPath();
ctx.strokeStyle = mojibanInfo.borderColor;
ctx.fillStyle = mojibanInfo.text.color;
ctx.lineWidth = mojibanInfo.borderWidth;
ctx.arc( 0 , 0 , hankei - mojibanInfo.borderWidth / 2, 0, Math.PI * 2, true);
ctx.stroke();
// 目盛りの表示
const topPos = hankei - mojibanInfo.borderWidth; // 外周分内側に置く
const rotateAngle = Math.PI * ( 360 / 60 ) / 180 ; // 一目盛りの角度
const line1 = memoriPath( topPos , mojibanInfo.line1); // 太い線のパスを取得
const line2 = memoriPath( topPos , mojibanInfo.line2); // 細い線のパスを取得
ctx.save(); // 回転させる前のコンテキストを保存
for( let i = 0 ; i < 60 ; i ++ ){ // キャンバスを回転させながら目盛りを描く
const line = i % 5 === 0 ? line1 : line2;
ctx.beginPath();
ctx.stroke( line );
ctx.rotate( rotateAngle );
}
ctx.restore(); // 保存したコンテキストを復元
// 時刻文字の表示
const r12 = 360 / 12; // 一文字の角度
const moziPos = topPos - mojibanInfo.text.dist;
const MathPi = Math.PI / 180;
// 文字の基準位置・フォントを設定
[ ctx.textAlign , ctx.textBaseline , ctx.font ] =
[ "center" , "middle" , mojibanInfo.text.font ];
for( let i = 0 ; i < 12 ; i ++){
const deg = i * r12 * MathPi ;
const [ mojiX , mojiY ] =
[ moziPos * Math.sin( deg ) , -moziPos * Math.cos( deg ) ] ;
ctx.fillText( i === 0 ? "12" : i.toString() , mojiX, mojiY );
}
};
};
目盛りは、12時の位置に描画した線を時計回りに回転しています。
数字は回転すると、数字そのものが回転してしまうので、サイン・コサインで回転後の座標を取得しています。
中心点から一定の距離にある点を、回転した座標を計算します。
今回は時計なので、一番上をゼロとして時計回りに回転させます。
計算式は、次のようになります。
cx = x + 距離 + 距離 × sinΘ
cy = y + 距離 - 距離 × cosΘ
Θ = 角度 × π ÷ 180
上のコードを動かしてみます。
JavaScript:テスト
window.addEventListener("DOMContentLoaded", () => {
const { context , hankei } = initialCanvas("analog");
const cCircle = centerCircle( context );
const mBan = mojiban ( context , hankei );
mBan( );
cCircle( );
});
文字盤が描けました。
時針・分針・秒針を描く
次は時針・分針・秒針です。
それぞれ同じコンストラクタから、オブジェクトを作成します。
JavaScript:時針・分針・秒針を描く
/*
* 針のデータ
*/
const handDatas = {
hour : { // 時針
width : 10 , // 幅
color : "#000", // 色
LengthPer:55, // 長さ(半径に対する割合)
handGapPer:10, // 反対側に飛び出る長さ(半径に対する割合)
divNum:12 * 60 // 一周の分割数
},
minute : { // 分針
width : 10 ,
color : "#000",
LengthPer:80,
handGapPer:10,
divNum:60
},
second : { // 秒針
width : 5 ,
color : "#f00",
LengthPer:85,
handGapPer:20,
divNum:60
},
};
/*
* 針の描画をおこなうオブジェクト
*/
const handObj = function( handData , context , hankei ){
this.handData = handData;
this.rotateAngle = Math.PI * ( 360 / handData.divNum ) / 180;
this.ctx = context;
const topPos = hankei - mojibanInfo.borderWidth;
// パスを作成
const pathCtx = new Path2D();
[ pathCtx.lineWidth , pathCtx.strokeStyle ] = [ handData.width , handData.color ];
pathCtx.moveTo( 0 , - ( topPos * handData.LengthPer / 100 ));
pathCtx.lineTo( 0 , topPos * handData.handGapPer / 100 );
this.pathCtx = pathCtx;
};
handObj.prototype={
rewrite : function ( val ) {
const ctx = this.ctx;
ctx.save();
ctx.beginPath();
[ ctx.lineWidth , ctx.strokeStyle ] = [ this.handData.width , this.handData.color ];
if( val !== 0 ){
ctx.rotate( this.rotateAngle * val );
}
ctx.stroke( this.pathCtx );
ctx.restore();
}
};
コンストラクタで針を描くパスを作成しておき、rewriteメソッドの呼び出して描画しています。
描画の考え方は、文字盤の目盛りと同じです。
これまでのコードを動かしてみます。
JavaScript:テスト
window.addEventListener("DOMContentLoaded", () => {
const { context , hankei } = initialCanvas("analog");
const cCircle = centerCircle( context );
const mBan = mojiban ( context , hankei );
const hourHand = new handObj( handDatas.hour , context , hankei );
const minuteHand = new handObj( handDatas.minute , context , hankei );
const secondHand = new handObj( handDatas.second , context , hankei );
const date = new Date();
mBan( );
hourHand.rewrite( (date.getHours()%12) * 60 + date.getMinutes() );
minuteHand.rewrite( date.getMinutes() );
secondHand.rewrite( date.getSeconds() );
cCircle( );
});
描画は上書きされていくので、文字盤→時針→分針→秒針→中央の円の順で実行していきます。
これで、Canvasにアナログ時計を描画できました。
完成:アナログ時計を動かす
最後に、setIntervalで一秒ごとに消去→描画を繰り返して、時計を完成させます。
これまでのコードもまとめて、あります。
JavaScript:アナログ時計完成コード
window.addEventListener("DOMContentLoaded", () => {
const { context , hankei } = initialCanvas("analog");
const cCircle = centerCircle( context );
const mBan = mojiban ( context , hankei );
const hourHand = new handObj( handDatas.hour , context , hankei );
const minuteHand = new handObj( handDatas.minute , context , hankei );
const secondHand = new handObj( handDatas.second , context , hankei );
const sideLength = hankei * 2;
setInterval(()=> {
const date = new Date();
// 時計を消去
context.clearRect(-hankei , -hankei , sideLength , sideLength);
mBan();
hourHand.rewrite( (date.getHours()%12) * 60 + date.getMinutes() );
minuteHand.rewrite( date.getMinutes() );
secondHand.rewrite( date.getSeconds() );
cCircle( );
},1000);
});
/*
* キャンバスの作成・初期化
*/
const initialCanvas = id => {
// div要素を取得
const viewElm = document.getElementById(id);
// div要素の縦横短い方を取得
const minSide = Math.min( viewElm.clientWidth , viewElm.clientHeight );
// 時計の半径を取得
const hankei = minSide / 2;
// キャンバスを作成
const cvs = document.createElement("canvas");
// キャンバスの描画コンテキストを取得
const context = cvs.getContext('2d');
// キャンバスの描画サイズセット
cvs.setAttribute( "width" , minSide );
cvs.setAttribute( "height" , minSide );
// キャンバスの表示サイズセット
const style = cvs.style;
style.width = minSide + "px";
style.height = minSide + "px";
// キャンバスをdiv要素の中央にセット
style.top = ( viewElm.clientHeight - minSide ) /2 + "px" ;
style.left =( viewElm.clientWidth - minSide ) /2 + "px" ;
// キャンバスにスタイルをセット
[ style.position , style.boxSizing , style.border ]=
[ "absolute" , "border-box" ,"0" ];
style.padding = style.margin = "0 0 0 0";
// 描画の原点をキャンバスの中心にセット
context.translate( hankei , hankei );
viewElm.appendChild( cvs );
return { hankei:hankei , context : context };
};
const centerCircleData = {
hankei:10, // 円の半径
width:4, // 線の太さ
lineColor: "black", // 線色
fillColor: "silver", // 塗りつぶし色
};
const centerCircle = ( context ) =>{
const ctx = context;
return () =>{
ctx.lineWidth = 0;
// 半径:centerCircleData.hankei の円を書く
ctx.beginPath();
ctx.fillStyle = centerCircleData.lineColor;
ctx.arc( 0 , 0 , centerCircleData.hankei , 0 , Math.PI * 2 );
ctx.fill();
// 半径:centerCircleData.hankei - centerCircleData.width の円を書く
ctx.beginPath();
ctx.fillStyle = centerCircleData.fillColor;
ctx.arc( 0 , 0 , centerCircleData.hankei - centerCircleData.width , 0 , Math.PI * 2 );
ctx.fill();
};
};
/*
* 文字盤描画データ
*/
const mojibanInfo ={
borderWidth : 3, // 外周の円の太さ
borderColor: "#000", // 外周の色
line1:{ // 太い目盛り
width: 5, // 線幅
height: 10 // 線の長さ
},
line2:{ // 細い目盛り
width: 3, // 線幅
height: 5 // 線の長さ
},
text:{ // 数字
dist:30, // 数字の中心の外周からの距離
color:"#000", // 数字の色
font:"bold 1.5em sans-serif" // 数字のフォント
}
};
/*
* 文字盤の描画
*/
const mojiban = ( context , hankei ) =>{
const ctx = context;
// パスを記憶
const memoriPath = ( hankei , type ) => {
const linePath = new Path2D();
linePath.lineWidth = type.width;
linePath.moveTo( 0 , hankei );
linePath.lineTo( 0 , hankei - type.height );
return linePath;
};
return ( ) => {
// 外側の円
ctx.beginPath();
ctx.strokeStyle = mojibanInfo.borderColor;
ctx.fillStyle = mojibanInfo.text.color;
ctx.lineWidth = mojibanInfo.borderWidth;
ctx.arc( 0 , 0 , hankei - mojibanInfo.borderWidth / 2, 0, Math.PI * 2, true);
ctx.stroke();
// 目盛りの表示
const topPos = hankei - mojibanInfo.borderWidth; // 外周分内側に置く
const rotateAngle = Math.PI * ( 360 / 60 ) / 180 ; // 一目盛りの角度
const line1 = memoriPath( topPos , mojibanInfo.line1); // 太い線のパスを取得
const line2 = memoriPath( topPos , mojibanInfo.line2); // 細い線のパスを取得
ctx.save(); // 回転させる前のコンテキストを保存
for( let i = 0 ; i < 60 ; i ++ ){ // キャンバスを回転させながら目盛りを描く
const line = i % 5 === 0 ? line1 : line2;
ctx.beginPath();
ctx.stroke( line );
ctx.rotate( rotateAngle );
}
ctx.restore(); // 保存したコンテキストを復元
// 時刻文字の表示
const r12 = 360 / 12; // 一文字の角度
const moziPos = topPos - mojibanInfo.text.dist;
const MathPi = Math.PI / 180;
// 文字の基準位置・フォントを設定
[ ctx.textAlign , ctx.textBaseline , ctx.font ] =
[ "center" , "middle" , mojibanInfo.text.font ];
for( let i = 0 ; i < 12 ; i ++){
const deg = i * r12 * MathPi ;
const [ mojiX , mojiY ] =
[ moziPos * Math.sin( deg ) , -moziPos * Math.cos( deg ) ] ;
ctx.fillText( i === 0 ? "12" : i.toString() , mojiX, mojiY );
}
};
};
/*
* 針のデータ
*/
const handDatas = {
hour : { // 時針
width : 10 , // 幅
color : "#000", // 色
LengthPer:55, // 長さ(半径に対する割合)
handGapPer:10, // 反対側に飛び出る長さ(半径に対する割合)
divNum:12 * 60 // 一周の分割数
},
minute : { // 分針
width : 10 ,
color : "#000",
LengthPer:80,
handGapPer:10,
divNum:60
},
second : { // 秒針
width : 5 ,
color : "#f00",
LengthPer:85,
handGapPer:20,
divNum:60
},
};
/*
* 針の描画をおこなうオブジェクト
*/
const handObj = function( handData , context , hankei ){
this.handData = handData;
this.rotateAngle = Math.PI * ( 360 / handData.divNum ) / 180;
this.ctx = context;
const topPos = hankei - mojibanInfo.borderWidth;
// パスを作成
const pathCtx = new Path2D();
[ pathCtx.lineWidth , pathCtx.strokeStyle ] = [ handData.width , handData.color ];
pathCtx.moveTo( 0 , - ( topPos * handData.LengthPer / 100 ));
pathCtx.lineTo( 0 , topPos * handData.handGapPer / 100 );
this.pathCtx = pathCtx;
};
handObj.prototype={
rewrite : function ( val ) {
const ctx = this.ctx;
ctx.save();
ctx.beginPath();
[ ctx.lineWidth , ctx.strokeStyle ] = [ this.handData.width , this.handData.color ];
if( val !== 0 ){
ctx.rotate( this.rotateAngle * val );
}
ctx.stroke( this.pathCtx );
ctx.restore();
}
};
アナログ時計:実行サンプル
レイヤーで管理する
これまでは、文字盤を含めたアナログ時計を全て消去して、再度書き直していました。
しかし同じものを毎回描画するのは、少し非効率に感じます。
そこでアナログ時計を、次の4つのパーツにわけ、各々レイヤーで管理してみます。
- 文字盤
- 時針
- 分針
- 秒針
- 中心の円
実際にはCanvasにはレイヤーの概念がないので、Canvasを重ねることでレイヤー構造を表現します。
今回は、上で紹介したコードを流用するため少し非効率です。
一例としてご利用ください。
JavaScript:Canvasをレイヤで管理する
/*
* レイヤー管理関数
*/
const intervallayerCtrl = ( id ) =>{
const viewElm = document.getElementById(id);
const minSide = Math.min( viewElm.clientWidth , viewElm.clientHeight );
const hankei = minSide / 2;
const layer = [];
const noFunc = function(){};
const clear = function () {
this.clearRect(-hankei , -hankei , minSide , minSide);
};
// 外部公開用メソッド
return {
addLayer( ){
const { context , hankei } = initialCanvas( id );
const lyObj = {
context:context,
hankei:hankei,
onInit : noFunc,
onRewrite : noFunc,
clear:clear.bind( context ),
}
layer.push( lyObj );
return lyObj;
},
start : () =>{
layer.forEach( e => e.onInit(e));
setInterval(()=> {
layer.forEach( e => e.onRewrite(e));
},1000);
}
};
};
このコードを実行すると、レイヤー追加メソッド(addLayer)と、描画開始メソッド(start)を持つオブジェクトを返します。
addLayerを実行すると、キャンバスが一枚追加され、いくつかのプロパティを持つオブジェクトが返ります。
addLayerの返り値 : {
context : 描画用コンテキスト
hankei : アナログ時計の半径,
onInit : 初期化時に呼ばれる関数
onRewrite : 描画時に呼ばれる関数
clear : キャンバスクリア関数
}
onInitとonRewriteに、各々の要素を描画する関数を登録することで、レイヤー毎の描画をおこなうことができます。
onInitは、初回に一度だけ呼び出されます。
文字盤と中心の円の描画をおこないます。
onRewriteは、一秒ごとに呼び出されます。
秒針・分針・時針の描画をおこないます。
JavaScript:Canvasをレイヤで管理する(実行部)
window.addEventListener("DOMContentLoaded", () => {
const layerObj = intervallayerCtrl( "analog" );
const layer0 = layerObj.addLayer();
const mBan = mojiban ( layer0.context , layer0.hankei );
layer0.onInit = lyObj => mBan();
const layer1 = layerObj.addLayer();
const hourHand = new handObj( handDatas.hour , layer1.context , layer1.hankei );
layer1.onRewrite = lyObj => {
lyObj.clear();
const date = new Date();hourHand.rewrite( (date.getHours()%12) * 60 + date.getMinutes() );
};
const layer2 = layerObj.addLayer();
const minuteHand = new handObj( handDatas.minute , layer2.context , layer2.hankei );
layer2.onRewrite = lyObj => {
lyObj.clear();
const date = new Date();minuteHand.rewrite( date.getMinutes() );
};
const layer3 = layerObj.addLayer();
const secondHand = new handObj( handDatas.second , layer3.context , layer3.hankei );
layer3.onRewrite = lyObj => {
lyObj.clear();
const date = new Date();secondHand.rewrite( date.getSeconds() );
};
const layer4 = layerObj.addLayer();
const cCircle = centerCircle( layer4.context );
layer4.onInit = lyObj => cCircle( );
layerObj.start();
});
Canvas要素を追加した順番で重なっていくので、順番を考慮してコードを作成する必要があります。
後から順番を変えたい場合、canvas要素のプロパティstyle.zindexに値をセットしてください。
更新日:2023/01/30
関連記事
スポンサーリンク
記事の内容について
こんにちはけーちゃんです。
説明するのって難しいですね。
「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。
裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。
掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。
ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php
このサイトは、リンクフリーです。大歓迎です。