Wednesday, July 9, 2014

Angular + PaperJS

In this sample I would like to demonstrate how to write an AngularJS directive which integrates with the PaperJS library.

One very powerful feature of AngularJS is its capability to extend the HTML vocabulary by directive implementation. Upon complete loading of the Angular library in a web page, it would walk through the page and compile the embedded directives into DOM model. Usually a directive is implemented either as HTML element or attribute, for example,

<my-directive />
<div my-directive />  

Of course directives can also be implemented in other forms like class and comment, but I don't go into details here. In the following sample, I would use attribute to implement my directive.

At the time of writing, I cannot find any official Angular directive to draw on the HTML canvas. Besides, I would like to make use of some Javascript graphics libraries (e.g. PaperJS, KineticJS...etc) so I do not need to deal with low level graphics details. There are some good hints to deal with canvas inside a directive in SO. Here I choose PaperJS for my experiment.

1. The HTML content


In the HTML page we need to specify the Angular application name. In the section, I specify a controller and include a canvas for our drawing.

<html ng-app="angularPaper">
   <head>
      <link href="css/bootstrap.min.css" rel="stylesheet" type="text/css"></link>
      <link href="css/stdtheme.css" rel="stylesheet" type="text/css"></link>
      <script src="js/angular.min.js"></script>
      <script src="js/ui-bootstrap-tpls-0.11.0.min.js"></script>
      <script src="js/paper.js"></script>
      <script src="js/goserver.js"></script>
      <script src="js/app.js"></script>
   </head>
   <body>
      <div role="main">
      <div class="container" ng-controller="BoardCtrl">
      <canvas board class="board-frame" height="500" width="500"></canvas>
      </div>
      </div>
   </body>
</html>

My application is called angularPaper and later we need to configure that in our angular codes in app.js. The goserver.js is another Javascript file which I use to contain the codes related to the Go board. Yes, I would like to draw a Go board. If you have no idea what this great game is, you'd better go to find it out now from wikipedia!!

In the above html, you will see that we enhance the canvas by a board attribute.  This attribute is not a standard attribute for the canvas element. We will implement the attribute using Angular directive.

Basically a Go board is a 19x19 grid like the following (image from wikipedia).There are also 13x13 and 9x9 boards for beginner level.




2. The Angular code

Next we need to write our angular module. The following shows a simple configuration of this sample:

'use strict';

var myApp = angular.module('angularPaper', ['ui.bootstrap']);

myApp.config(function($locationProvider) {
  $locationProvider.html5Mode(false);
});

myApp.controller('BoardCtrl', function($scope) {
  $scope.boardSize = 19;
  $scope.boardControl = {};
}); 

The above configuration defines a controller named BoardCtrl, which has a variable boardSize to define the size of board. Our directive will need this variable later to create a board. The boardControl object is used to expose some public operations defined inside the directive indirectly. It may not be the best solution for a controller to change the directive state, but it is certainly a simple approach. Another solution is to create a service to act as a communication bridge between the controller and directive (see this).


3. The Directive

The final bit is to actually write our directive, which uses PaperJS inside to draw the Go board. According to Angular guidelines, all codes related to the "view" or DOM manipulation should be written inside a directive. Therefore, it is the place where we do all the drawing and event handling (e.g. a mouse click on the board).

First, I define the directive placeholder like this:

myApp.directive('board', ['$timeout', function(timer) {
  return {
    restrict: 'A',
    scope: true,
    link: function(scope, element) {
    }
  };
}]);

This is an attribute directive, so I specify 'A' for the restrict property. Also I intend to use inherited scope from the controller so I set true to scope. The link function will contain the main logic for drawing and mouse event handling.

A drawEmptyBoard function is created to draw the board on the provided canvas element:
      /*
       * Function to draw an empty board. This function will be called whenever
       * user reset the board.
       */
      function drawEmptyBoard(element) {

        // create a new empty board
        scope.board = goServer.createBoard(scope.boardSize);

        // setup the paper scope on canvas
        if (!scope.paper) {
          scope.paper = new paper.PaperScope();
          scope.paper.setup(element);
        }

        // clear all drawing items on active layer
        scope.paper.project.activeLayer.removeChildren();

        // draw the board
        var size = parseInt(scope.boardSize);
        var width = element.offsetWidth;
        var margin;
        switch (size) {
        case 9:
          margin = width * 0.08;
          break;
        case 13:
          margin = width * 0.05;
          break;
        default:
          margin = width * 0.033;
        }
        scope.interval = (width - 2 * margin) / (size - 1);

        // store the coordinates for mouse event detection
        scope.coord = [];
        var x = margin;
        for (var i = 0; i < size; i++) {
          scope.coord[i] = x;
          x += scope.interval;
        }

        // assign the global paper object
        paper = scope.paper;

        // draw the board grid
        for (var i = 0; i < size; i++) {

          // draw x axis
          var from = new paper.Point(scope.coord[0], scope.coord[i]);
          var to = new paper.Point(scope.coord[size - 1], scope.coord[i]);
          var path = new paper.Path();
          path.strokeColor = 'black';
          path.moveTo(from);
          path.lineTo(to);

          // draw y axis
          from = new paper.Point(scope.coord[i], scope.coord[0]);
          to = new paper.Point(scope.coord[i], scope.coord[size - 1]);
          path = new paper.Path();
          path.strokeColor = 'black';
          path.moveTo(from);
          path.lineTo(to);
        }

        paper.view.draw();
      }


A tricky point here is, we cannot call the draw board function right away inside the link function. Invoking the function inside the link function has no effect at all and we cannot see our board. A workaround is to include a timer call to invoke the drawing function immediately at the end of the link function. That's the reason why I inject the $timeout service to the directive:

myApp.directive('board', ['$timeout', function(timer) {
  return {
    restrict: 'A',
    scope: true,
    link: function(scope, element) {
      function drawEmptyBoard(element) {
        // the code draws the board
      }

      timer(drawEmptyBoard(element[0]), 0);
    }
  };
}]);

Finally, I added a mousedown event handling to detect my mouse click and draw the black/white stones alternatively.
myApp.directive('board', ['$timeout', function(timer) {

  return {
    restrict: 'A',
    scope: true,
    link: function(scope, element) {

      /*
       * Use an internalControl object to expose operations
       */
      scope.internalControl = scope.boardControl || {};
      scope.internalControl.resetBoard = function() {
        drawEmptyBoard(element[0]);
      };

      /*
       * Function to draw an empty board. This function will be called whenever
       * user reset the board.
       */
      function drawEmptyBoard(element) {
        // the code draws the board
      }

      // bind the mouse down event to the element
      element.bind('mousedown', function(event) {
        var x, y;
        if (event.offsetX !== undefined) {
          x = event.offsetX;
          y = event.offsetY;
        } else { // Firefox compatibility
          x = event.layerX - event.currentTarget.offsetLeft;
          y = event.layerY - event.currentTarget.offsetTop;
        }

        // find the stone position
        x = Math.round((x - scope.coord[0]) / scope.interval);
        y = Math.round((y - scope.coord[0]) / scope.interval);

        if (!scope.board.isAllowed(x, y)) { return; }

        // draw the stone
        paper = scope.paper;
        var center = new paper.Point(scope.coord[x], scope.coord[y]);
        var circle = new paper.Path.Circle(center, scope.interval / 2 - 1);
        if (scope.board.nextColor() == goServer.moveType.BLACK) {
          circle.fillColor = 'black';
        } else {
          circle.fillColor = 'white';
          circle.strokeColor = 'black';
          circle.strokeWidth = 1;
        }
        paper.view.draw();

        scope.board.addMove(x, y);
      });

      timer(drawEmptyBoard(element[0]), 0);
    }
  };

}]);


A function called InternalControl is added to the inherited scope so that external code can invoke the exposed functions through the controller control variable.

The code has been put in GitHub. A running sample is also available at Plunker:  http://plnkr.co/edit/m6rq2o.


No comments:

Post a Comment

Note: Only a member of this blog may post a comment.