プログラマブログ

by wacul

menu

2014.04.30Canvasのお絵かきに bacon.js を使ってみた

イベント処理の憂鬱とbacon.js

業務でちょっとしたお絵かきツールをjsで作る機会がありました。
マウスのイベントを拾ってcanvasに描画する、それだけの処理なのですが、イベントのハンドリングとペンの状態などが絡むと、結構メンテがしづらいソースになってしまいます。

これを綺麗に書く方法はないか、調べている中で、bacon.js というライブラリを見つけました。試してみたところなかなか良さそうだったのでご紹介します。

bacon.js はFRP(Functional Reactive programmer) という概念をjsで実装したもので、僕なりの理解だと、

  • 変化するイベントや値 (EventStream, Property) をオブジェクトとして扱うことができる
  • イベントや値の間に関係を持たせることができる 例: b = a + 1
  • 元の値が変わると、関係する値も自動的に更新される 例: a が 1 –> 2 に変化すると、bは 2 –> 3 に変化する (Reactive)
  • 値の変換を関数として記述できる (Functional)
1
2
//例: c は a + b
c = a.combine(b, function(a, b){ return a + b })

といった特徴があります。
FRPの概念については、そんなに深追いしてないのですが、FRPをiOS/OSXのCocoa APIの上に実装した ReactiveCocoaなどのライブラリもあります。

お絵かきアプリを bacon.js で実装してみる

さて、今回はお絵かきアプリのサンプルをbacon.jsを使って書いてみました。

動いているデモ
ソースコード(github)

機能としては以下を実装しています

  • マウス、タッチイベント両方で描画できる
  • 色、太さを選択できる

使っているライブラリは、jQuery, bacon.js, bacon.UI.JS( jQueryのイベントをbacon.jsで扱うためのライブラリ)です。

マウス、タッチイベントから、描画に必要な座標の流れに変換する

描画に必要な情報は、描画に必要な座標の変化の流れです。 時間にそって、x,yの組が変化していくストリームを抽象化し、そのストリームを描画する処理と切り離します。

ここが少しどう実装するか迷ったポイントだったのですが、 描画には開始と終了があるので、 一連のx,y座標のストリームを返すストリーム として実装するとうまくいくことがわかりました。

sample.js:L9-21 エレメントに対するマウスのイベントからx,y 座標の組のストリームを生成するストリームを返す関数を定義しています

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 // マウスでの描画座標ストリーム生成
  var mouseDrawStreamSource = function(element){
    return $(element).asEventStream('mousedown').doAction('.preventDefault').map(function(){
      return $(document).asEventStream('mousemove').doAction('.preventDefault').takeUntil(
        $(document).asEventStream('mouseup').doAction('.preventDefault')
      ).map(function(e){
        var offset = $(element).offset();
        return {
          x : e.pageX - offset.left,
          y : e.pageY - offset.top
        };
      });
    });
  };

sample.js:L24-55 タッチイベントに関しても同じインターフェイスのストリームを返せるような関数を書きます

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
  // タッチでの描画座標ストリーム生成
  var touchDrawStreamSource = function(element){
    return $(element).asEventStream('touchstart').doAction('.preventDefault').flatMap(function(e){
      return Bacon.fromArray(e.originalEvent.changedTouches);
    }).map(function(touch){
      var filterEventContainesTouch = function(stream){
        return stream.map(
          '.originalEvent.changedTouches'
        ).filter(function(touches){
          return _(touches).contains(touch)
        });
      };

      var endStream = filterEventContainesTouch(Bacon.mergeAll([
        $(document).asEventStream('touchend').doAction('.preventDefault'),
        $(document).asEventStream('touches').doAction('.preventDefault')
      ]));

      return filterEventContainesTouch(
        $(document).asEventStream('touchmove').doAction(
          '.preventDefault'
        ).takeUntil(
          endStream
        )
      ).map(function(e){
        var offset = $(element).offset();
        return {
          x : touch.pageX - offset.left,
          y : touch.pageY - offset.top
        };
      });
    });
  };

sample.js:L57-59 それぞれのストリームをcanvasから生成して、マウス、タッチの入力を両方とも受け付けられるように、2つのストリームを統合します

1
2
3
  var mouseStreamSource = mouseDrawStreamSource($('#canvas'));
  var touchStreamSource = touchDrawStreamSource($('#canvas'));
  var strokeStreamSource = mouseStreamSource.merge(touchStreamSource);

strokeStreamSource は、描画に必要な情報のみ提供していて、マウスとかタッチのことはうまく隠蔽されているのがわかると思います。

ペンの設定と描画処理

sample.js:L61-71 次に、色と太さをProperty(状態を持ったイベントストリーム) として定義します
option の値を、太さは数値、色はrgb値の文字列に値を変換しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  // 太さ
  var sizeProperty = Bacon.UI.optionValue($('[name=size]'), '2').map(function(val){
    return parseInt(val, 10);
  });

  // 色
  var colorProperty = Bacon.UI.optionValue($('[name=color]'), 'red').decode({
    red : 'rgb(255, 0, 0)',
    green : 'rgb(0, 255, 0)',
    blue: 'rgb(0, 0, 255)'
  });

  var options = Bacon.combineTemplate({
    size : sizeProperty,
    color : colorProperty
  });

最後に色、太さの設定情報とx,y座標のストリームを使って、描画処理を書きます。
描画が発生したときに、設定の値を取得しています。

L78-105

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  // 描画
  options.sampledBy(strokeStreamSource, function(options, stream){
    return { options : options, stream : stream };
  }).onValue(function(args){
    var options = args.options;
    var stream = args.stream;

    // 2点の組みで返すストリーム
    var pointSetStream = stream.slidingWindow(2).filter(function(points){
      return points.length == 2;
    }).filter(function(points){
      var fromPoint = points[0], toPoint = points[1];
      return (fromPoint.x < canvas.width && fromPoint.y < canvas.height) ||
        (toPoint.x < canvas.width && toPoint.y < canvas.height)
    });

    pointSetStream.onValue(function(points){
      var fromPoint = points[0], toPoint = points[1];

      ctx.strokeStyle = options.color;
      ctx.lineWidth = options.size;

      ctx.beginPath();
      ctx.moveTo(fromPoint.x, fromPoint.y);
      ctx.lineTo(toPoint.x, toPoint.y);
      ctx.stroke();
    });
  });

サンプルコードのまとめ

イベントの流れを抽象化することで、様々な入力に容易に対応できるコードにすることができました。
入力を増やすには、他のイベントから座標の流れのイベントに変換すれば良いので

  • Webソケットを使ってリアルタイムにサーバーから送られてくる座標を描画
  • 保存されたお絵かきを再生

なども描画処理を変えることなく実装できそうです。 出力についても、描画した線をベジェ曲線に近似させる、といったことも入力とは独立して実装することができます。

bacon.js を使ってみて

今回は、簡単なお絵かきツールの実装に沿ってbacon.jsを使ってみましたが、なかなか良さそうです。

イベントの捉え方を根本的に変えないといけないので、チーム開発でのメンテ性などを考えるとアプリ全体をbacon.jsを使って書くのはまだ厳しい気もします。
パーツ単位で、特に今回のようなお絵かきやソケット通信などのイベントが主役の部分については、うまく抽象化できて可読性、メンテ性の高いコードが書けるなという印象をうけました。

面白いので、みなさんもぜひ使ってみてください。

この記事を書いた人tutuming

株式会社ワカルの技術責任者です。フロントエンドからバックエンドまで、ひと通りやってます。最近の興味はチームづくりと、パンづくりです。

waculでは、プログラマを募集しています。

現在はプロダクトとして、課題発見から改善提案まで自動で行うWeb改善プラットフォーム「AIアナリスト」を開発中です。

waculの採用情報へ

ページトップへ