ノンプログラマにCanvasを使ったアニメーションについて教えたった話


Hola!えいやです。

先日、近所に住んでいるスタートアップを目指すノンプログラマな元軍人がCanvasでのアニメーションについて聞きたいと訪ねてきたので教えたった話です。

ノンプログラマが相手ですので、JavaScriptの基礎の話です。
また、今回の相談は以前作って完成した物に手を加える話なのでCanvas成分低めです。

## アニメーションと時間処理

アニメーションを行うには、時間によって処理を実行するタイミングを決められなければなりません。

そこでCanvasやアニメーションの話は置いといて、時間処理を行う方法について勉強しましょう。

### 時間処理の関数、setTimeout()とsetInterval()

JavaScirptで時間処理を行うためには、setTimeout()もしくはsetInterval()を用います。

まずはこれらの使い方を学びましょう。

※ 以下の例では、わかりやすくするためHTML部分を除いています。試す場合は適当なHTMLファイルを用意して、scriptタグでjsファイルを読み込むか、scriptタグ中にコードを記載してください。

#### JavaScriptの書き方基礎

以下のように書きましょう。これはグローバル変数の消費を抑えるためのお作法です。興味があればスコープについて調べましょう。他の書き方もありますが、今回はこれで書きます。

```JavaScript:base.js
(function(){
    :
 書きたい処理
    :
})();
```

#### setTimeout()

setTimeout関数は、一定時間後に一度だけ処理を実行するときに使います。
引数には、処理(関数またはJavaScriptとして実行可能な文字列)と時間(ms:1000分の1秒)を指定します。

試してみましょう。
ブラウザのJavaScriptコンソールを開いて、以下のコードを実行します。

```JavaScript:timeout1.js
(function(){
  var onTimeout = function(){
    console.log(new Date());
  };
  console.log(new Date());
  setTimeout(onTimeout,1000);
})();
```

コンソールに、時間を示す文字が2行表示されるはずです。

以下のフォーマットや時間は適当ですが、だいたいこんな感じですね。

```
DateObject - 2013/02/13 00:00:00
DateObject - 2013/02/13 00:00:01
```

それぞれの行を表示しているのは、console.log(new Date())という部分です。これが実行されるとコンソールにその時の時間を表示します。

timeout1.jsにはこの記述が2箇所あります。
3行目と、5行目です。

3行目は、onTimeoutという関数の定義中に含まれるため、3行目の実行のタイミングは、onTimeout関数の実行時です。

5行目は、このコードの実行時にすぐ表示されます。
つまりログの一行目は、5行目のconsole.log(new Date())の結果、その1秒後のログは3行目のconsole.log(new Date())の結果です。

上記の結果のように、1秒待ってonTimeout()を実行できる事がわかりました。

### setInterval()

setInterval関数は、一定時間おきに処理を何度も実行するときに使います。
引数には、やはり処理(関数またはJavaScriptとして実行可能な文字列)と時間(ms:1000分の1秒)を指定します。

試してみましょう。
ブラウザのJavaScriptコンソールを開いて、以下のコードを実行します。

```JavaScript:interval1.js
(function(){
  var onTimeout = function(){
    console.log(new Date());
  };
  console.log(new Date());
  setInterval(onTimeout,1000);
})();
```

コンソールに、時間を示す文字がずっと表示されるはずです。

以下のフォーマットや時間は適当ですが、だいたいこんな感じですね。

```
DateObject - 2013/02/13 00:00:00
DateObject - 2013/02/13 00:00:01
DateObject - 2013/02/13 00:00:02
DateObject - 2013/02/13 00:00:03
DateObject - 2013/02/13 00:00:04
     :
     :
  ずっと続く
     :
```

上記の結果のように、1秒おきにonTimeout()を実行できる事がわかりました。

setInterval()はclearInterval()を使うことで停止させることができます。

clearInterval()を使う場合は、以下のようにsetInterval()が返却するオブジェクトを確保しておく必要があります。確保したオブジェクトをclearInterval()に引数として与えることで、次回のタイミング以降の処理の実行を停止させられます。

```JavaScript:interval2.js
(function(){
  var onTimeout = function(){
    console.log(new Date());
  };
  console.log(new Date());
  var interval = setInterval(onTimeout,1000);
  clearInterval(interval);
})();
```

上記のコードでは、ログは1行しか表示されません。
setInterval()のあとclearInterval()が1秒以内に実行されるからです。

##### 注意点

勘違いしやすい点ですが、setInterval()もsetTimeout()もプログラムの動作を停止して待つのではありません。
これらは指定した処理を、指定した時間経過後に実行する機能です。

つまり、setInterval()もsetTimeout()も次の行に書いた処理は即座に実行されます。
コンピューターの性能によりますが、次の行が実行されるまでの時間は、1マイクロ秒あるかないかというところです。

また、もしclearInterval()を実行したとき、既に指定した処理が実行中だった場合は、その処理を途中で停止したりはしません。次のタイミング以降で指定した処理が実行されないようになります。

### 問題「一秒ごとに10回処理を続けて停止させましょう」

では、時間処理をきちんと理解してコードをかけるようになるために、問題を解きましょう。
3つの条件を出しますので、条件にしたがって解決してください。
なお、処理の間隔は大体1秒毎であればいいです。数マイクロ秒程度の誤差は許容範囲です。
また、ログが0秒目から9秒目までか、1秒目から10秒目まで出るのかは特に気にしなくて良いです。

1. setInterval()、clearInterval()とsetTimeout()を組み合わせること
2. setTimeout()のみを用いること
3. 2.の解決でループを使っている場合は、さらにループを使わないこと(ループで実装できる冗長なコードはダメ)

#### 条件1. setInterval()、clearInterval()とsetTimeout()を組み合わせること

この条件は簡単ですね。
setInterval()を実行したあと、10秒ちょっと待ってからclearInterval()を実行すればよいのです。

すなわち、コードは以下のようになります。

```JavaScript:sol1.js
(function(){
  var onInterval = function(){
    console.log(new Date());
  };
  var interval = setInterval(onInterval,1000);

  var onTimeout = function(){
    clearInterval(interval);
  }
  setTimeout(onTimeout,1000*10 + 1); // <- 10秒より長く11秒より短い時間を指定
})();
```

#### 条件2. setTimeout()のみを用いること

この方法には、色々と解決方法を思いつきますが、シンプルにループを用いることにしましょう。

待つ時間が、1,2,3,…,10秒となるsetTimeout()を10回行えば良いのです。
すなわち、コードは以下のようになります。

```JavaScript:sol2.js
(function(){
  var onTimeout = function(){
    console.log(new Date());
  };

  for(var i=1;i<=10;i++){
    setTimeout(onTimeout,1000 * i); // <- 待ち時間が1秒ずつ増える
  }
})();
```

#### 条件3. 条件2.の解決でループを使っている場合は、さらにループを使わないこと

この方法では、少し頭を使います。

ループを使わないで1秒ずつ間隔を開けるには、前回の実行から更に1秒待つようにしなければなりません。

そこで、タイムアウトで実行される関数の中でもう一度setTimeout()を呼び出すようにします。

さらに、それだけでは処理が10回で止まらないので条件を設けます。
実行した回数を数えられるように変数を用意して、10回未満のときのみsetTimeout()を呼び出すようにします。

すなわち、コードは以下のようになります。

```JavaScript:sol3.js
(function(){
  var count = 0;
  var onTimeout = function(){
    count++;
    console.log(new Date());
    if(count < 10){
      setTimeout(onTimeout,1000);
    }
  };
  onTimeout();
})();
```

### 3つの実装方法の違い

3つの実装方法の違いの主なところは、処理を止める方法です。

1つ目の方法では、onTimeout()中でclearInterval()を使って処理を止めています。

* メリットは、実装がシンプルで分かりやすいことです。
* デメリットは、何かの問題によってonTimeout()が実行されなかったり、onTimeout()でclearInterval()が実行されなかったり、変数intervalが書き換えられていたりする場合に処理が永遠に続いてしまうことです。

2つ目の方法では、それぞれの処理別々にタイムアウトをして、勝手に止まることを期待します。

* メリットは、実装が比較的シンプルであることです。
* デメリットは、もし順番が重要な処理だったとき、例えば2秒目の処理が途中で止まったり、時間が1秒以上かかったときでも、3秒目以降の処理が予定通り実行されてしまう可能性があることです。

3つ目の方法では、リレーのように処理を繋げて指定の回数実行されたら処理の連鎖を止めています。

* メリットは、処理が順番通りに行われることを保証できること、問題があった場合、途中で止まることです。
* デメリットは、実装がやや複雑なこと、回数を数えている変数が変更される可能性があること、一度の処理の時間が長い場合に指定回数の処理が終えるまでの時間が長くなることです。

### アニメーションで使うべき方法

上記のような特徴があることを踏まえて、アニメーションをする場合はどれがいいかを考えます。

* 処理が順番通りに行われないと困る。
* 問題があったら止まらないと困る。

ので、条件3で行った方法を用いて実装するのがよさそうですね。

## アニメーションを作ろう

今回相談を持ちかけられたのは、[以前紹介したグラフつきの画像](http://qiita.com/items/add9d658add7f6307b1c)についてです。
![image](https://www.dropbox.com/s/svyxjpsqct1uzvs/meter_image.png?dl=1)

ページを表示した時に、このバーが指定の数値の場所まで上がるようなアニメーションを付けたいというのが相談でした。

このグラフ表示のためのスクリプトは以下のようになっています。なお、前回の記事から幾分手を加えてあります。

```JavaScript:old_meter.js
// (c) aya_eiya - 2013
(function (){
  this.setMeter = function(_id,_size,imageSrc,_percent){
    var percent = _percent;
    var size = _size;
    var canvas = document.createElement('canvas');
    var context = canvas.getContext("2d");
    var meterDiv = document.getElementById(_id);
        meterDiv.appendChild(canvas);
    var image = new Image();
    var transparent = "rgba(0,0,0,0)";
    var meterColor = "rgb(255,128,128)";
    var meterWidth = 16;
    var digs = {0:0,90:Math.PI/2,180:Math.PI,360:Math.PI*2};

    var margin = 0;
    var setMargin = function(){
      margin = size * 0.1;
      canvas.width = size + margin * 2;
      canvas.height = size + margin * 2;
    };

    var setFrameOfMeter = function(){
      context.strokeStyle = meterColor;
      context.lineWidth = 2;
      context.beginPath();
      context.arc(size/2 + margin,size/2 + margin, size/2 - meterWidth,digs[0],digs[360]);
      context.stroke();
      context.beginPath();
      context.arc(size/2 + margin,size/2 + margin, size/2,digs[0],digs[360]);
      context.stroke();
    };

    var setMerterValue = function(){
      context.strokeStyle = meterColor;
      context.lineWidth = meterWidth;
      context.beginPath();
      var lDig = -percent/100 * digs[180] + digs[90];
      var rDig =  percent/100 * digs[180] + digs[90];
      context.arc(size/2 + margin,size/2 + margin,size/2 - meterWidth/2, lDig,rDig);
      context.stroke();
    };

    var setImageToCercle = function(){
      var aspect = image.width/image.height;
      context.strokeStyle = transparent;
      context.beginPath();
      context.arc(size/2 + margin,size/2 + margin, size/2 - meterWidth,digs[0],digs[360]);
      context.stroke();
      context.save();
      context.clip();
      context.drawImage(image,margin,margin,size * aspect,size);
      context.restore();
    };

    var setTextArea = function(){
      var fontSize = size * 0.18;
      context.font = fontSize + "px Arial";
      var text = percent+"%";
      var textSize = context.measureText(text);
      var textLeft = size * 0.9;
      context.strokeStyle = meterColor;
      context.fillStyle = "white";
      context.lineWidth = 4;
      context.beginPath();
      context.save();
      context.arc(textLeft,size - fontSize/2,textSize.width/2+5,0,digs[360]);
      context.fill();
      context.stroke();
      context.clip();
      context.beginPath();
      context.fillStyle = "black";
      context.fillText(text,textLeft-textSize.width/2,size-fontSize/8);
      context.restore();
    };

    var ready = false;

    var setUp = function(){
      setMargin();
      setImageToCercle();
      setFrameOfMeter();
      setMerterValue();
      setTextArea();
      ready = true;
    };

    image.onload = setUp;
    var setImage = function(imageSrc){
      ready = false;
      image.src = imageSrc;
    };
    var setPercentage = function(_percent){
      percent = _percent;
      if(ready) setUp();
    };

    var setSize = function(_size){
      console.log(_size);
      size = _size;
      if(ready) setUp();
    };

    setImage(imageSrc);

    return {setSize:setSize,setImage:setImage,setPercentage:setPercentage};
  };
})();
```

このコードに、先ほど学んだ時間処理を加えます。
その際に、互換性を保つために、アニメーションを行うかどうかを、setMeterの引数として指定するようにします。

コードの変更点は以下のようになります。

```JavaScript:new_meter.js
// (c) aya_eiya - 2013
- function(_id,_size,imageSrc,_percent){
+ function(_id,_size,imageSrc,_percent,_isAutoAnimation){
+    var isAutoAnimation = (_isAutoAnimation)?true:false;
+    var autoAnimationSpeed = 500; // 1 - 1000
 
     var setMerterValue = function(){
+       var tmpPercent = (isAutoAnimation)?0:percent;
+       var onTimeout = function(){
+         tmpPercent += 5;
+         if(tmpPercent > percent) tmpPercent = percent;
          context.strokeStyle = meterColor;
          context.lineWidth = meterWidth;
          context.beginPath();
-         var lDig = -percent/100 * digs[180] + digs[90];
-         var rDig =  percent/100 * digs[180] + digs[90];
+         var lDig = -tmpPercent/100 * digs[180] + digs[90];
+         var rDig =  tmpPercent/100 * digs[180] + digs[90];
          context.arc(size/2 + margin,size/2 + margin,size/2 - meterWidth/2, lDig,rDig);
          context.stroke();
+         if(isAutoAnimation) setTextArea(); // 描画順のため
+         if(tmpPercent < percent) setTimeout(onTimeout,1000/autoAnimationSpeed);
+      };
+       onTimeout();
      };

+     var setAutoAnimationStatus = function(){
+       if(isAutoAnimation){
+         autoAnimationSpeed = (autoAnimationSpeed < 1)?1:autoAnimationSpeed;
+         autoAnimationSpeed = (autoAnimationSpeed > 1000)?1000:autoAnimationSpeed;
+       }
+     };

      var setUp = function(){
+       setAutoAnimationStatus();
        setMargin();
        setImageToCercle();
        setFrameOfMeter();
        setMerterValue();
        setTextArea();
        ready = true;
      };
```

setMerterValue()の処理をみてみると、条件3で行った方法とよく似たコードになっているのがわかると思います。

元のコードでは、lDig、rDigで指定するバーの角度(高さ)を指定されたパーセンテージそのままに設定していたのに対し、変更したコードでは、tmpPercent変数を用意して、時間経過ととともに徐々にあげるようにしています。

実行結果については、GitHubからDLして確かめてください。
https://github.com/aya-eiya/html5meter_sample