From Pixels to Objects

by kirupa   |   filed under Working with the Canvas

So far, one thing has held true about what we've done on the canvas. Once a pixel makes it way to the screen, that's it. Everything about how it got there, what properties make up the larger shape we were intending to draw, and so on are completely lost. Do you want to modify whatever it is you just drew? Well...you can't. The best you can do is clear the screen and draw a whole lot of new pixels instead. Fortunately (or unfortunately) this behavior is a characteristic of an immediate mode system:

There is no intermediate layer of abstraction that stores every detail about every pixel you have drawn.

For what we've been doing so far, that limitation has been OK. We have only been drawing one-off lines and shapes, so this very primitive and direct way of drawing pixels hasn't really slowed us down. As we start going down the path of creating more elaborate visuals involving many drawn elements or (gasp!) animated and interactive elements, having some way of controlling the pixels in a very DOM-lite way with a richer scene / model is desirable. In this tutorial, we're going to look at how to do that.

Onwards!

Moving Away from Directly Dealing with Pixels

Looking backwards, what we want to do is work with our shapes in a way that doesn't involve directly tracking and updating pixels. All of that pixel-related work will still happen, but we will be working at a higher level of abstraction. We will be taking our canvas-drawn shapes and treating them like the more "refined" DOM elements we have in a retained mode system. There are several degrees of getting this DOM-like refinement going. We are going start at the beginning and slowly turn the crank with more involved examples.

Our Starting Point

We are going to be getting a little code heavy, and nothing beats following along to the words you see here to make sense of it all. To get started, create a new HTML page and add the following markup into it:

<!DOCTYPE html>
<html>
 
<head>
  <title>From Pixels to Objects</title>
 
  <style>
    body {
      margin: 0px;
      padding: 50px;
    }

    #myCanvas {
      border: 1px #CCC solid;
    }
  </style>
</head>
 
<body>
  <div id="container">
    <canvas id="myCanvas" width="550" height="350">
 
    </canvas>
  </div>
 
  <script>

  </script>
</body>
 
</html>

If you preview this page right now, you shouldn't see anything interesting happening. We just have our canvas element, some styles, and a script tag. That's pretty much it. Don't worry. We will be changing all of that up in a few moments.

Functions Make Everything Fun

The first approach we will look at is to rely on functions to make our drawing commands easier to use. Inside your script tags, go ahead and add the following lines of JavaScript:

var mainCanvas = document.querySelector("#myCanvas");
var mainContext = mainCanvas.getContext("2d");

function draw(xPos, yPos, radius, color) {
  mainContext.beginPath();

  mainContext.arc(xPos, yPos, radius, 0, 2 * Math.PI, true);
  mainContext.closePath();

  mainContext.fillStyle = color;
  mainContext.fill();
}

function drawCircle() {
  draw(200, 150, 75, "#51D6FF");
}
drawCircle();

Save these changes and preview them in your browser to make sure everything works. If everything worked, you should see one blue circle appear similar to the following:

How that circle got there isn't too much of a mystery. Let's go back to our code and look at what exactly is happening. The first function that gets called is drawCircle, and it contains a call to the draw function with some arguments specified:

function drawCircle() {
  draw(200, 150, 75, "#51D6FF");
}
drawCircle();

It is this draw function that is responsible for drawing the circle to the screen and having it look the way it does. It looks like the following:

function draw(xPos, yPos, radius, color) {
  mainContext.beginPath();

  mainContext.arc(xPos, yPos, radius, 0, 2 * Math.PI, true);
  mainContext.closePath();

  mainContext.fillStyle = color;
  mainContext.fill();
}

The entirety of this code is just the calls we've already seen for drawing a circle on the canvas. The only variation is that we aren't hard-coding some of the circle's properties. The circle's position, size, and color are passed in as arguments instead. The end result is a circle drawn to our specifications as defined by our draw call in our drawCircle function. That's neat, right?

Taking a step back, what we have seen here is nice. By using functions to reduce the tediousness of these drawing calls, we simplify how we get pixels to display on screen. What we haven't done is move the needle in making the shapes we draw more maintainable. If we wanted to alter how our circle gets drawn, it will require us manually clearing the canvas and re-drawing the circle at the appropriate location. That isn't exactly what we want. We need a better approach.

It's Object Time

What functions bring to the table in simplicity, they lack in reusability when it comes to the approach we just saw. Once a pixel has been drawn, there is no lingering memory of how it got there. The way we are going to fix this is by relying on mapping our shape to an object. This object will be responsible for everything our shape does such as painting the initial pixels, updating its appearance, and so on. The way we are going to do this is by first creating a class called Circle that will act as the template for the objects we will create.

Go ahead and delete the draw function from our code and replace it with the following:

class Circle {
  constructor(xPos, yPos, radius, color) {
    this.radius = radius;
    this.xPos = xPos;
    this.yPos = yPos;
    this.color = color;
  }

  draw() {
    mainContext.beginPath();

    mainContext.arc(this.xPos, this.yPos, this.radius, 0, 2 * Math.PI, true);
    mainContext.closePath();

    mainContext.fillStyle = this.color;
    mainContext.fill();
  }
}

Our Circle class contains a constructor method that sets some variables passed in as arguments. These arguments, just like what we saw earlier with the draw function, specify the position, size, and color of the circle we wish to draw.

Next, we have a draw method. This method simply draws the circle. Before we go further, let's see all of this in action. Go ahead and modify the contents of our drawCircle function to create our Circle object and call the draw method on it:

function drawCircle() {
  var blueCircle = new Circle(200, 150, 75, "#51D6FF");
  blueCircle.draw();
}

If you preview what we have right now, what you see should be identical to what we saw earlier:

This isn't a glitch! All we have done is replace our more function-oriented code with one that more directly relies on objects. The arguments we pass in as part of creating each of our circles is identical to what we passed earlier when calling the draw function. That's why the circle we see on screen looks the same as what we had before.

What is the major difference? The major difference is that our circle (blueCircle) is represented by an object. That doesn't mean a whole lot...yet. We really can't do much with these objects and the circles they represent. If we wanted to update any of the circles to look slightly different, we can't do that. We are going to fix that next, and we are going to do that by creating a few methods for setting the position, size, and color.

Modify our Circle definition by adding the highlighted lines:

class Circle {
  constructor(xPos, yPos, radius, color) {
    this.radius = radius;
    this.xPos = xPos;
    this.yPos = yPos;
    this.color = color;
  }

  setPosition(xPos, yPos) {
    this.xPos = xPos;
    this.yPos = yPos;
  }

  setRadius(radius) {
    this.radius = radius;
  }

  setColor(color) {
    this.color = color;
  }
  
  draw() {
    mainContext.beginPath();

    mainContext.arc(this.xPos, this.yPos, this.radius, 0, 2 * Math.PI, true);
    mainContext.closePath();

    mainContext.fillStyle = this.color;
    mainContext.fill();
  }
}

What we have just done is add the setPosition, setRadius, and setColor methods that help update the position, radius, and color properties with values we specify on an existing Circle object. To see this code in action, add the highlighted two lines to our drawCircle function:

function drawCircle() {
  var blueCircle = new Circle(200, 150, 75, "#51D6FF");
  blueCircle.draw();

  blueCircle.setPosition(50, 50);
  blueCircle.draw();
}

We are changing the position of our blueCircle circle and calling the draw method to paint the updated pixels to the screen. When we preview this change, you'll see something that looks like the following:

We now two five circles painted on the screen. We see the original circle that mimics what we saw earlier, but we see an additional circle where our blue circle seems to be duplicated at the (50, 50) position. I'm pretty sure that's not what we wanted when we decided to update our circle's position. The expectation was that the circle will shift from its current position to the new position - not that it would be duplicated!

The reason for this behavior goes back to how drawing on the canvas works. When we draw a pixel on screen, those pixels are permanently stuck there unless we explicitly remove them. This means us updating the visuals of our circle shouldn't just involve updating some property values. It should also involve a repaint operation where the circle in its original state is removed and the circle in its new state is painted on. We are going to all of that in our drawCircle method using the clearRect method we've seen many times in the past.

Make the following highlighted addition to our drawCircle method code:

function drawCircle() {
  var blueCircle = new Circle(200, 150, 75, "#51D6FF");
  blueCircle.draw();
  
  mainContext.clearRect(0, 0, mainCanvas.width, mainCanvas.height);
  blueCircle.setPosition(50, 50);
  blueCircle.draw();
}

If you preview the changes now, you will see something similar to the following:

All we did was add our clearRect call and specify the size of the region we want to repaint. This single line of code ensures that, when called just before we change any part of our circle's appearance, what we end up seeing is only the final state. There is one slight problem with the line we added, though. It is a bit too specific to our implementation and not generic enough to meet our goal of not working with the pixels and related mechanics directly. We are going to fix this by moving our clearRect code to our Circle class definition. Make the following highlighted change to do just that:

class Circle {
  constructor(xPos, yPos, radius, color) {
    this.radius = radius;
    this.xPos = xPos;
    this.yPos = yPos;
    this.color = color;
  }

  static clearAll(canvas, context) {
    context.clearRect(0, 0, canvas.width, canvas.height);
  }

  setPosition(xPos, yPos) {
    this.xPos = xPos;
    this.yPos = yPos;
  }

  setRadius(radius) {
    this.radius = radius;
  }

  setColor(color) {
    this.color = color;
  }
  
  draw() {
    mainContext.beginPath();

    mainContext.arc(this.xPos, this.yPos, this.radius, 0, 2 * Math.PI, true);
    mainContext.closePath();

    mainContext.fillStyle = this.color;
    mainContext.fill();
  }
}

The code we added created a static method called clearAll that takes our canvas element and its associated context as arguments. Inside, it just calls clearRect like we have seen in the past. All we have to do is just call it, so make the following highlighted change in our drawCircle function where we replace our direct call to clearRect with a call to our clearAll method instead:

function drawCircle() {
  var blueCircle = new Circle(200, 150, 75, "#51D6FF");
  blueCircle.draw();
  
  Circle.clearAll(mainCanvas, mainContext);
  
  blueCircle.setPosition(50, 50);
  blueCircle.draw();
}

If you test our code again by running our example in the browser, what you see will still be the same behavior. You will still see just a single blue circle. The only change is that we cleared our canvas in a more generic, abstract way compared to what we did a few moments ago. That's a good thing!

Drawing (and Dealing With) Many Shapes

Now that we've seen how to draw just a single shape, there is one more thing we'll look at before wrapping things up. Let's look at how to draw and keep track of many shapes. Replace the code in our drawCircle function with the following that paints 40 circles to the screen:

function drawCircle() {
  for (var i = 0; i < 40; i++) {
    var r = Math.round(15 + Math.random() * 150);
  
    var xPos = Math.round(Math.random() * mainCanvas.width);
    var yPos = Math.round(Math.random() * mainCanvas.height);
  
    var newCircle = new Circle(xPos, yPos, r, "rgba(41, 170, 255, .1)");
    newCircle.draw();
  }
}

All we are doing here is creating a loop that creates a new Circle object every time it runs. Each object gets a random position and radius value that get passed in as part of creation. If you preview your page now, you will see something that looks as follows:

Yikes! What is going on here? Shouldn't we be seeing about 40 circles on the screen? We are only seeing just one! The reason for this weird behavior has to do with the clearRect call we added to our draw method in the previous section. Each time the draw method is called, the entire canvas is cleared of all that is currently there. This means we only see the last circle getting painted.

The fix for this is simple. We can just remove the clearRect call from the draw method. This makes our draw method go back to the state it was at some point earlier:

function draw(xPos, yPos, radius, color) {
  mainContext.beginPath();

  mainContext.arc(xPos, yPos, radius, 0, 2 * Math.PI, true);
  mainContext.closePath();

  mainContext.fillStyle = color;
  mainContext.fill();
}

If you remove the line containing clearRect and preview the change, what you will see will be something like the following:

What you will see is all 40 circles displaying in their unique way with a different position and radius value for each, barring any random values that happen to be the same. Now, what if we want to change the color of all these circles to be red. How would we go about doing that? The answer lies in taking advantage of the fact that every circle you see is backed by an object. All we need is a way of keeping track of these objects for access and modification later. The way we will keep track is by relying on an array.

Go ahead and make the following changes just above and inside the drawCircle function:

var circles = [];

function drawCircle() {
  for (var i = 0; i < 40; i++) {
    var r = Math.round(15 + Math.random() * 150);
  
    var xPos = Math.round(Math.random() * mainCanvas.width);
    var yPos = Math.round(Math.random() * mainCanvas.height);
  
    var newCircle = new Circle(xPos, yPos, r, "rgba(41, 170, 255, .3)");
    newCircle.draw();

    circles.push(newCircle);
  }
}

What we are doing is creating an array called circles. Each time we create a new Circle object, we store a reference to that object in our circles array. What this allows us to do is reference each of our created circles at a later time. This means we can change all of our circles to be red colored whenever we want to as long as we have a reference to all the circles we created. Let's make that color change when we click or tap anywhere on the canvas.

To do that, add the following lines below where we have our drawCircle code:

mainCanvas.addEventListener("mousedown", changeColor, false);

function changeColor() {
  Circle.clearAll(mainCanvas, mainContext);

  for (var i = 0; i < circles.length; i++) {
    var circle = circles[i];

    circle.setColor("rgba(251, 80, 18, .3)");
    circle.draw();
  }
}

If you preview your page now, you will start off by seeing all of the blue circles inside our canvas. Click or tap anywhere on the canvas to see all of these blue circles turn red:

The code for making this possible lives inside the changeColor function we just added:

function changeColor() {
  Circle.clearAll(mainCanvas, mainContext);

  for (var i = 0; i < circles.length; i++) {
    var circle = circles[i];

    circle.setColor("rgba(251, 80, 18, .3)");
    circle.draw();
  }
}

The first thing we do is clear the screen of all our existing circles. This allows us to then go through each circle as stored in our circles array, call setColor, specify a red color value, and then call the draw method to get the new pixels to paint on screen.

The final code for everything we've seen so far looks as follows:

var mainCanvas = document.querySelector("#myCanvas");
var mainContext = mainCanvas.getContext("2d");

class Circle {
  constructor(xPos, yPos, radius, color) {
    this.radius = radius;
    this.xPos = xPos;
    this.yPos = yPos;
    this.color = color;
  }
  
  static clearAll(canvas, context) {
    context.clearRect(0, 0, canvas.width, canvas.height);
  }

  setPosition(xPos, yPos) {
    this.xPos = xPos;
    this.yPos = yPos;
  }

  setRadius(radius) {
    this.radius = radius;
  }

  setColor(color) {
    this.color = color;
  }
  
  draw() {
    mainContext.beginPath();

    mainContext.arc(this.xPos, this.yPos, this.radius, 0, 2 * Math.PI, true);
    mainContext.closePath();

    mainContext.fillStyle = this.color;
    mainContext.fill();
  }
}

var circles = [];

function drawCircle() {
  for (var i = 0; i < 40; i++) {
    var r = Math.round(15 + Math.random() * 150);
  
    var xPos = Math.round(Math.random() * mainCanvas.width);
    var yPos = Math.round(Math.random() * mainCanvas.height);
  
    var newCircle = new Circle(xPos, yPos, r, "rgba(41, 170, 255, .3)");
    newCircle.draw();

    circles.push(newCircle);
  }
}
drawCircle();

mainCanvas.addEventListener("mousedown", changeColor, false);

function changeColor() {
  Circle.clearAll(mainCanvas, mainContext);

  for (var i = 0; i < circles.length; i++) {
    var circle = circles[i];

    circle.setColor("rgba(251, 80, 18, .3)");
    circle.draw();
  }
}

If you are having issues with any of the code from the various sections, be sure to double check what you have with the final version you see here.

Conclusion

If we take a step back at everything we've done here, all we did is change one way of organizing our code (using functions) and replaced it with another (using objects). Sometimes, neither of these approaches are necessary. For simple drawings, all of this is overkill. That's why we didn't do anything like this in the many articles prior to this where we looked at each drawing method by itself. Now, as you start to create more complex designs, add animations, or throw in some interactivity, this fast-and-carefree way will end up hurting you in the long run. It makes your code less maintainable, and adding functionality later on becomes a hassle. At that point, you'll be glad to pay the extra initial cost and use something like object approach we looked at in this article.

If you have a question about this or any other topic, the easiest thing is to drop by our forums where a bunch of the friendliest people you'll ever run into will be happy to help you out!

THE KIRUPA NEWSLETTER

Get cool tips, tricks, selfies, and more...personally hand-delivered to your inbox!

( View past issues for an idea of what you've been missing out on all this time! )

GOT A QUESTION?

HOT FORUM TOPICS

Serving you freshly baked content since 1998!

Killer hosting by (mt) mediatemple

Facebook Twitter Youtube Pinterest Instagram Github
BACK TO TOP
new books - yay!!!