HTML5 Canvas Drawing Library Exploration: Paper.js

This is the fourth in an ongoing series of posts exploring some Javascript drawing libraries. Earlier posts cover Easel, oCanvas, and Raphael.

Paper.js is an interesting library. It is a Javascript port of the Scriptographer tool used in Adobe Illustrator, and much effort has been made to keep the code used in each tool interchangeable. It does a good job of opening the programming door for illustrators looking to create designs which, if created by hand, would be labor-intensive and/or complex to the point of being nearly impossible.

The code for Paper.js is a little more complex than in the previous libraries I have covered. Mouse interactions are a little more take a little more work to set up, and the number of drawing tools is quite large. Paper.js allows the creation of a custom DOM within the canvas element, and that makes it possible to create quite UI-intensive applications.

Click to Launch the demo.

HTML

<!DOCTYPE html>
<html>
	<head>
		<title>Paper.js demo</title>
		<style>
			* {margin:0;padding:0;}
			body {background:#ffffff;margin:0;padding:0;overflow:hidden;}
			#myCanvas {position:absolute;left:0;top:0;width:800px;height:480px;background:#ffffff;}
		</style>
		<script type="text/javascript" src="paper.js"></script>
	</head>
	<body>
		<canvas id="myCanvas" width="800" height="480"></canvas>
		<script type="text/paperscript" canvas="myCanvas">
			//	paperscript code goes here
		</script>
	</body>
</html>

The HTML is mostly identical to that used in the Easel and oCanvas demos, with one exception: Notice the <script/> element in the body. It has a custom type (“text/paperscript”), and a custom attribute (“canvas”), which are used by the Paper.js library to set up the page. Paperscript is simply Javascript with some additional properties and methods. It is possible to use Paper.js with pure Javascript, and the only difference would be some more verbose coding.

Javascript/Paperscript

/*	global variables	*/
var currentShape="circle";
var	isPaused = true;
var	isInitialized = false;
var buttons = [];
var pauseButton;

/*	math and positioning	*/
var shapes = ["circle","tricuspoid","tetracuspoid","epicycloid","epicycloid 2","epicycloid 3","lissajous","lemniscate","butterfly"];
var w = 800;
var h = 480;
var centerX = 240;
var centerY = 240;
var radius_x = 150;
var radius_y = 150;
var theta = 0;
var objects = [];
var numObjects = 0;
var r2d = 180/Math.PI;
var d2r = Math.PI/180;
var orbitSteps = 180;
var orbitSpeed = Math.PI*2/orbitSteps;
var objectInterval;
var objectPosition;
var direction = 1;
var index = 0;
var xVar1 = 0;
var xVar2 = 0;
var xVar3 = 0;
var xVar4 = 0;
var startingObjects = 100;
var newX;
var newY;

initInterface();
initObjects();
onFrame();
isInitialized = true;
function initInterface() {
	var xOff = 650;
	var yOff = 25;
	for(var i=0;i<shapes.length;i++) {
		var bGroup = new Group();
		var btn = new Rectangle(new Point(0,0),new Size(150,20));
		var btnPath = new Path.Rectangle(btn);
		btnPath.fillColor="#ededed";
		btnPath.strokeColor="#808080";
		btnPath.strokeWidth = 1;
		
		btnPath.shapeName = shapes[i];
		
		var txt = new PointText(new Point(75,12));
		txt.content = shapes[i];
		txt.characterStyle= {
			font:"Courier",
			fontSize:10,
			fillColor:"#000000"
		}
		txt.paragraphStyle = {
			justification:"center"
		};
		bGroup.addChild(btnPath);
		bGroup.addChild(txt);
		bGroup.position.x = xOff;
		bGroup.position.y = yOff;
		bGroup.value = shapes[i];
		buttons.push(btnPath);
		yOff += 23;
	}
	yOff += 23;
	
	var pauseButtonGroup = new Group();
	var pauseButtonRect = new Rectangle(new Point(0,0),new Size(150,20));
	pauseButton = new Path.Rectangle(pauseButtonRect);
	pauseButton.fillColor="#ededed";
	pauseButton.strokeColor="#808080";
	btnPath.strokeWidth = 1;
	var pauseButtonText = new PointText(new Point(75,12));
	pauseButtonText.content = "PLAY/PAUSE";
	pauseButtonText.characterStyle= {
		font:"Courier",
		fontSize:10,
		fillColor:"#000000"
	}
	pauseButtonText.paragraphStyle = {
		justification:"center"
	};
	pauseButtonGroup.addChild(pauseButton);
	pauseButtonGroup.addChild(pauseButtonText);
	pauseButtonGroup.position.x = xOff;
	pauseButtonGroup.position.y = yOff;
}

function initObjects() {
	for(var i=0;i<startingObjects;i++) {
		addObject();
	}
}
function addObject() {
	var obj = new Path.Circle(new Point(centerX,centerY),5);
	obj.style={
		fillColor:'#'+randomRGB()+randomRGB()+randomRGB(),
		strokeColor:'#000000'
	};
	objects.push(obj);
	numObjects = objects.length;
	objectInterval = orbitSteps/numObjects;
}
function removeObject() {
	numObjects = objects.length;
	objectInterval = orbitSteps/numObjects;
}

function randomRGB(){
	var s = Math.floor(Math.random()*256).toString(16);
	if(s.length==1) s = "0"+s;
	return s;
}


function onMouseDown(e) {
	for(var i=0;i<buttons.length;i++) {
		if(buttons[i].hitTest(e.point)) {
			currentShape = buttons[i].shapeName;
			isInitialized = false;
			onFrame();
			isInitialized = true;
		}
	}
	if(pauseButton.hitTest(e.point)) {
		isPaused = !isPaused;
	}
}
function onMouseMove(e) {
	for(var i=0;i<buttons.length;i++) {
		if(buttons[i].hitTest(e.point)) {
			buttons[i].fillColor="#ffffff";
		} else {
			buttons[i].fillColor="#ededed";
		}
	}
	if(pauseButton.hitTest(e.point)) {
		pauseButton.fillColor="#ffffff";
	} else {
		pauseButton.fillColor="#ededed";
	}
}

function onFrame(e) {
	if(isPaused==true && isInitialized==true) return;
	for(var i = 0; i < numObjects; i++) {
		objectPosition = orbitSpeed*objectInterval*i;    //    each object is individually updated
		switch(currentShape) {
			case "circle":
				newX = centerX + radius_x * Math.cos(theta + objectPosition);
				newY = centerY + radius_y * Math.sin(theta + objectPosition);
				break;
			case "tricuspoid":
				newX = centerX + (radius_x*.5) * ((2 * Math.cos(theta + objectPosition)) + Math.cos(2 * (theta + objectPosition)));
				newY = centerY + (radius_y*.5) * ((2 * Math.sin(theta + objectPosition)) - Math.sin(2 * (theta + objectPosition)));
				break;
			case "tetracuspoid":
				newX = centerX + radius_x * Math.pow((Math.cos(theta + objectPosition)),3);
				newY = centerY + radius_y * Math.pow((Math.sin(theta + objectPosition)),3);
				break;
			case "epicycloid":
				newX = centerX + (radius_x*.4) * Math.cos(theta + objectPosition) - radius_x*1*(Math.cos((radius_x/radius_x + 1) * (theta + objectPosition)));
				newY = centerY + (radius_y*.4) * Math.sin(theta + objectPosition) - radius_y*1*(Math.sin((radius_y/radius_y + 1) * (theta + objectPosition)));
				break;
			case "epicycloid 2":
				newX = centerX + (radius_x*.4) * Math.cos(theta + objectPosition) - radius_x*1*(Math.cos((radius_x/radius_x + 2) * (theta + objectPosition)));
				newY = centerY + (radius_y*.4) * Math.sin(theta + objectPosition) - radius_y*1*(Math.sin((radius_y/radius_y + 2) * (theta + objectPosition)));
				break;
			case "epicycloid 3":
				newX = centerX + (radius_x*.4) * Math.cos(theta + objectPosition) - radius_x*1*(Math.cos((radius_x/radius_x + 3) * (theta + objectPosition)));
				newY = centerY + (radius_y*.4) * Math.sin(theta + objectPosition) - radius_y*1*(Math.sin((radius_y/radius_y + 3) * (theta + objectPosition)));
				break;
			case "lissajous":
				newX = centerX + radius_x * (Math.sin(3 * (theta + objectPosition) + xVar1));
				newY = centerY + radius_y * Math.sin(theta + objectPosition);
				xVar1 += .002;
				break;
			case "lemniscate":
				newX = centerX + (radius_x*1.2) * ((Math.cos(theta + objectPosition)/(1 + Math.pow(Math.sin(theta + objectPosition),2))));
				newY = centerY + (radius_y*1.2) * (Math.sin(theta + objectPosition) * (Math.cos(theta + objectPosition)/(1 + Math.pow(Math.sin(theta + objectPosition),2))));
				break;
			case "butterfly":
				newX = centerX + (radius_x*.4) * (Math.cos(theta + objectPosition) * (Math.pow(5,Math.cos(theta+objectPosition)) - 2 * Math.cos(4 * (theta+objectPosition)) - Math.pow(Math.sin((theta+objectPosition)/12),4)));
				newY = centerY + (radius_y*.4) * (Math.sin(theta + objectPosition) * (Math.pow(5,Math.cos(theta+objectPosition)) - 2 * Math.cos(4 * (theta+objectPosition)) - Math.pow(Math.sin((theta+objectPosition)/12),4)));
				break;
			default:
				break;
	
		}
		objects[i].position.x = newX;
		objects[i].position.y = newY;
	}
	theta += (orbitSpeed*direction);
}

Here is a line-by-line breakdown of the above Paperscript:

  • 2-6 – initialize some global variables. Note that there is a “pauseButton” variable here. This will be important later.
  • 9-33 – instantiating variables for the trigonometry animations. Nothing here is Paper.js specific.
  • 35 – method call to create the user interface
  • 36 – method call to create the circle graphics used in the animation
  • 37 – display the initial pattern
  • 38 – set the state of the app to “initialized”
  • 39-92 – initInterface() – create the button used to control the animations
    • 40-41 – initialize variables for positioning the UI buttons
    • 42-69 – iterate through the shapes array and create a button for each element therein
      • 43 – create a Group object to hold the elements that make up a button
      • 44 – create a Rectangle object
      • 45 – create a display object to display the Rectangle on the stage
      • 46-48 – style the rectangle
      • 50 – associate the name of the associated animation pattern with this button
      • 52 – create and position a new PointText object for displaying text on the stage.
      • 53 – add text to the PointText object
      • 54-58 – style the text in the text object
      • 59-61 – justify the text in the button. This causes the registration point for the object to be centered. See also the x and y values for the text object created on line 52
      • 62-63 – add the gray rectangle and the text to the Group for this button
      • 64-65 – position this button on the stage
      • 66 – associate the name of the animation shape with this button
      • 67 – add this button to the array of button objects
      • 68 – iterate the y position variable for the buttons
    • 70 – add some space between the preceding buttons and the play/pause button
    • 72-91 – create the play/pause button the same way the as the buttons created on lines 43-67
  • 94-98 – initObjects() – create a number of circle graphics equal to the value of the startingObjects variable
  • 99-108 – addObject() – create a circle graphic and add it to the stage
    • 100 – create a new Circle display object
    • 101-104 – color in the circle and give it an outline
    • 105 – add the circle to the objects array
    • 106 – increase the value of numObjects by one
    • 107 – adjust the spacing of the circle graphics in the animation
  • 109-112 – removeObjects() – not used in this demo
  • 114-118 – randomRGB() – create and return a random hexadecimal value between 0 and 255, inclusive.
  • 121-133 – onMouseDown() – listen for the mouseDown event on the stage
    • 122-129 – iterate through the buttons to see if one of them intersected the click event
      • 123 – if a point on a button has intersected the the coordinates of the mouse click…
      • 124 – set the current animation shape to equal the shape associated with that button
      • 125-127 – update the screen to reflect this change (these lines are only needed if the animation is paused)
    • 130-132 – check to see if the play/pause button intersected the click coordinates, and if so, toggle the isPaused boolean value
  • 134-147 – onMouseMove() – listen for mouse move events on the stage
    • 135-141 – iterate through the buttons to see if one of them is currently being moused over
      • 136-137 – if a button is being moused over, set its fill color to white
      • 138-140 – if not, set its fill color to light gray
    • 142-143 – if the pause button is moused over, set its fill color to white.
    • 144-146 – if not, set its fill color to light gray
  • 149-199 – onFrame(e) – built-in Paper.js method to allow frame-based animations
    • 150 – if the animation is paused, do nothing
    • 151-197 – iterate through the circle graphics and update the position of each
      • 152-194 – for each circle, figure out where it should be based on its position within the list of circle objects, and the value of the currentShape variable
      • 195-196 – update the position of the circle graphic on the stage
    • 198 – update the rotation of the animation for the next frame

So there it is: a trigonometric curves explorer app, done up with Paper.js. It takes a little more work to get things on the screen, and event handling is not as simple as some other libraries, but the level of control over the graphics, and the performance compared to Easel, oCanas, and Raphael, more than make up for the difference.

As with the other posts in this series, the code for the various shapes of the animations comes from the Simple Trigonometric Curves Tutorial over at Kongregate.

Enjoy!