Contents

How to create christmas tree - Canvas API

What you will learn

  • how to use canvas API to draw shapes
  • how to use Path2D to define shapes
  • how to draw only inside a specific Path2D object
  • how to create infinite color generator
  • how to use JavaScript objects as configuration for functions
  • how to use arrow functions as providers for different types of data
  • how to refactor existing code during the process of creating more complex drawings

Preparation

Let’s start with adding two elements to html page: canvas (the text inside will be displayed by the browser only if the browser is incapable of rendering the canvas element) and script which will contain a constant representing canvas element and the code that draws on the canvas:

1
2
3
4
<canvas id="tree" width=800 height=300>Canvas-rendered Christmas tree</canvas>
<script>
   ...
</script>

Drawing background

Inside the script you can put this simple code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
function basicCtx(){
  const t = document.getElementById("tree");
  const ctx = t.getContext('2d');
  t.width = 400;
  t.height = 300;
  ctx.fillStyle = 'navy';
  ctx.fillRect(0, 0, t.width, t.height);
}

basic();

This code:

  • finds an element with id tree in the document - the canvas element t
  • uses the canvas element to get a drawing context - ctx
  • sets the fill color (which is part of the context state) to navy
  • draws a filled rectangle with top left at (0,0) and bottom right at (t.width, t.height)

This is the canvas:

Drawing a tree shape

The next step is drawing the christmas tree shape. This will be just a simple isosceles triangle. I will assume that:

  • top vertex of the tree is at 0.9 of the total height, in the middle of the available horizontal space
  • bottom vertices of the tree create a base of the tree and are at the 0.1 of the vertical space
  • calculation of vertices coordinates require knowing the width and height of the available space, so I need to refactor the basicCtx so that it receives the id of the canvas and width and height and returns the ctx (so that it can be passed to a separate function for drawing)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function basicCtx(wi, hi, name){
  const t = document.getElementById(name);
  const ctx = t.getContext('2d');
  t.width = wi;
  t.height = hi;
  ctx.fillStyle = "navy";
  ctx.fillRect(0, 0, t.width, t.height);
  return ctx;
}

let [wi, hi] = [400, 300];
let ctx = basicCtx(wi, hi, "tree");

Then I need to calculate the coordinates of the vertices. I use Vertex function to create new vertices. Drawing and filling the polygon consisting of vertices I’ve just created is done in drawVertices function which takes:

  • drawing contest
  • color
  • an array of vertices

and uses several functions of the drawing context, namely beginPath, moveTo, lineTo and closePath to create a path. Such path is then filled using fill function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let bottom_len = Math.min(wi, hi);
const vtop = new Vertex(wi/2, 0.1*hi);
const lbot = new Vertex((wi - bottom_len)/2, 0.9*hi);
const rbot = new Vertex(lbot.x + bottom_len, lbot.y);


drawVertices(ctx, "green", [vtop, lbot, rbot]);

function Vertex(x, y) {
  this.x = x;
  this.y = y;
}

function drawVertices(ctx, c, vs) {
  ctx.beginPath();
  ctx.moveTo(vs[0].x, vs[0].y);
  for (let i = 1; i < vs.length; i++) {
    ctx.lineTo(vs[i].x, vs[i].y);
  }
  ctx.closePath();
  ctx.fillStyle = c;
  ctx.fill();
}

Gradient

Next step is to use a gradient instead of a simple color. In a call to drawVertices I will use a linear gradient. It will start at vtop vertex and will end up at the bottom of the tree. It will consist of two colors only:

1
2
3
4
5
let ctx = basicCtx(wi, hi, "tree");
let g = ctx.createLinearGradient(vtop.x, vtop.y, vtop.x, vtop.y + hi);
g.addColorStop(0, "goldenrod");
g.addColorStop(0.7, "green");
drawVertices(ctx, g, [vtop, lbot, rbot]);

It’s worth extracting the gradient creation code to a separate function, which will receive data necessary to create a simple gradient: two colors and a “color stop” of the second color (which take value from [0, 1] range and represent a location on gradient line where this exact color appears). The first “color stop” is always at 0 as the gradient expands starting from the top pf the tree.

Let’s write a function named linear which takes gradient configuration and returns a one-argument function. This function, when given a context - would create and return a gradient.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function linear({from: f, to: t, start:s end: e}){
  return c => {
    let g = c.createLinearGradient(vtop.x, vtop.y, vtop.x, vtop.y + hi);
    g.addColorStop(s || 0, f);
    g.addColorStop(e || 1, t);
    return g;
  }
}

let ctx = basicCtx(wi, hi, "tree");
const g = linear({from: "goldenrod", to: "green", start:0, end: 0.7})(ctx);
drawVertices(ctx, g, [vtop, lbot, rbot]);

A path

One of the ways of creating complex drawings in JavaScript API is using a Path2D interface which allows not only to create polygons but also to create arbitrary SVG paths.

Let’s change the code that draws and fills the tree in drawVertices so that it takes a context, a path and a color and fills the path with the color on the context.

The path creation is a different task than path drawing so it can be extracted to a separate function:

  • createVerticesPath function takes a list of vertices (Vertex) and returns a closed path; I use this function to create treePath - a Path2D object representing the tree/rectangle
  • drawVertices function is renamed to drawPath so that the name refects better what happens inside: it draws a path
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function createVerticesPath(vs) {
  const ctx = new Path2D();
  ctx.moveTo(vs[0].x, vs[0].y);
  for (let i = 1; i < vs.length; i++) {
    ctx.lineTo(vs[i].x, vs[i].y);
  }
  ctx.closePath();
  return ctx;
}

function drawPath(path, ctx, c) {
  ctx.fillStyle = c;
  ctx.fill(path);
}

let treePath = createVerticesPath([vtop, lbot, rbot]);
drawPath(treePath,  ctx, g);

Joining paths

In order to create a path that consists of many subpaths - and to be able to fill all the subpaths at once, for example - I need to use addPath method.

See below:

  • I use a treePath Path2D for drawing a tree
  • I create a path with many subpaths: createBaubles function returns a table with paths representing baubles and joinPaths joins all paths into one path (all baubles are joined together)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
function joinPaths(ps) {
  let paths = new Path2D();
  for (let i = 0; i < ps.length; i++) {
    paths.addPath(ps[i]);
  }
  return paths;
}

function createBaubles(n, r, wid = wi, hig = hi) {
  let baubles = new Array();
  for (let i = 0; i < n; i++) {
    const by = Math.random()*hig;
    const bx = Math.random()*wid;
    const b = new Path2D();
    b.arc(bx, by, r, 0, 2*Math.PI);
    baubles.push(b);
  }
  return baubles;
}

const baubles = createBaubles(10, 10);
drawPath(joinPaths(baubles), ctx, "red");

And here’s the picture:

Clipping path

Well, the baubles need to be tamed a bit: I will draw them only inside treePath path. I wil use clip method in CanvasRenderingContext2D interface .

After calling this method, everything that is drawn on the context on which this method was called will not be drawn outside of the current path (if clip is called without arguments) ir outside of the given path (if a path was given as argument) and will only be visible inside it.

I will use treePath as a clipping path and I use it on main context; drawing random baubles will now be restricted to (and visible inside of) the treePath.

This is the code:

1
2
3
drawPath(treePath, ctx, g);
ctx.clip(treePath);
drawPath(createBaubles(50, 10), ctx, "red");

And this is a tree with “tamed” baubles:

Colors for the baubles

Red color is too intense and bites the eyes. I want each bauble to be of different, less intense color.

I will not join baubles but draw baubles in the loop, using next color from the color generator:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function *colorGenerator({sat:s, lig:l, del:d}) {
  let curr = 0;
  while (true) {
    yield `hsl(${curr},${s}%, ${l}%)`;
    curr += d;
  }
}

const bs = createBaubles(100, 10);
const colorGen = colorGenerator({sat: 80, lig: 80, del: 10});
for (let b of bs) {
  let nextColor = colorGen.next().value;
  drawPath(b, ctx6, nextColor);
}

The above generator uses different hue values (increasing in steps defined by del) for generating colors, but saturation and lightness are given as generator’s configuration. I also use default values: for saturation it is 80, for lightness it is 80, for delta it is 10.

A drawing:

A gradient in the background

I want to use a gradient in the background, so I will change basicCtx. Now it has a hardcoded background color:

1
2
3
4
5
6
7
8
9
function basicCtx(wi, hi, name){
  const t = document.getElementById(name);
  const ctx = t.getContext('2d');
  t.width = wi;
  t.height = hi;
  ctx.fillStyle = "navy";
  ctx.fillRect(0, 0, t.width, t.height);
  return ctx;
}

I will pass a function that can create something to be used to fill the background (color, gradient or pattern). By default, this function will return the color which is currently hardcoded. But my goal is to pass a function returned by gradient-cretaing linear function:

1
2
3
4
5
6
7
8
9
function basicCtx(wi, hi, name, fillfn=c=>"navy"){
  const t = document.getElementById(name);
  const ctx = t.getContext('2d');
  t.width = wi;
  t.height = hi;
  ctx.fillStyle = fillfn(ctx);
  ctx.fillRect(0, 0, t.width, t.height);
  return ctx;
}

And now I can easily create simplel gradients for a background and for a tree shape:

1
2
3
4
5

const bggrad = linear({to: "midnightblue",from: "royalblue", end: 0.7});
const gp = linear({to: "purple", from: "pink", end: 0.7});
let ctx = basicCtx(wi, hi, "tree", fillfn=bggrad);
drawPath(treePath, ctx6, gp(ctx6));

More christmas trees!

I want to draw more christmas trees, so that my readers know that the blog post is really in the right season. Let’s plant a few of the trees next to each other.

I will write drawTrees function, that receives a context and a configuration object. The object contains:

  • gradientFn - function that retuns the fill for the tree
  • outclip - function that - when called with a context - will draw something using the whole context (before a call to clip)
  • inclip - function that - when called with a context - will draw something inside the clip path, i.e. the tree shape (after a call to clip)
  • treesCount - number of trees
  • treePath - path of a tree, also used as a clip path
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function drawTrees(ctx, {treesCount: cnt = 1, gradientFn =(c)=>"green", inclip, outclip}){
  for (let i = 0; i < cnt; i++) {
    ctx.save();
    ctx.translate(i * w, 0);

    drawPath(tp, ctx, gradientFn(ctx));

    if (outclip !== undefined) {
      outclip(ctx);
    }
    
    ctx.clip(tp);

    if (inclip !== undefined) {
      inclip(ctx);
    }
    ctx.restore();
  }
}

Baubles drawing function will also be parametrized - configuration object passed to drawBaubles contains three elemens:

  • count - number ob baubles
  • colFn - function that returns the color of the next bauble
  • rFn - function returning the size of the bauble
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

function drawBaubles(ctx, {
      count = 10, 
      colFn = () => "red", 
      rFn = () => 5}
) {
    let bs = createBaubles(count, rFn());
    for (let b of bs) {
      let nextColor = colFn);
      drawPath(b, ctx, nextColor);
    }
}

The last step is the creation of the configuration object for drawing baubles and, finally, doing the actual call to drawTrees.

I also implement callable function that will wrap a generator into a function that can simply be called to get next value from the generator. This way I can wrap existing colorGenerator and pass it as colFn parameter to drawBaubles.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

function callable(gen) {
  return () => gen.next().value;
}

let bCfg = {
  count: 200, 
  rFn: () => 3 + Math.random() * 10, 
  colFn: callable(colorGenerator({sat: 70, lig: 60, del: 10}))
};

let [wim, him, w, h] = [750, 300, 150, 300];
let cnt = wim/w ;
let vs = tlrVertices(w, h, 0.1, Math.min(w, h)*0.9);
let tp = createVerticesPath(vs);

let ctx = basicCtx(wim, him, "tree", 
  fillfn = linear({from: "indigo", to:"midnightblue"}));

drawTrees(ctx, {
  treePath: tp,
  treesCount: cnt,
  inclip: (ctx) => drawBaubles(ctx, bCfg)
});

Chains, stars and snow

It would be nice to place a gold star at the top of each tree, to put some christmas tree chains and have white snow falling down!

Chains are drawn inside a clip path (I simply draw arcs), but the stars are drawn outside a clip path - this is how I can re-use or extend drawTrees (which is now a bit like a Template Method) - I just carefully construct inclip and outclip values of configuration object.

Merry Christmas!

 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
let ctx = basicCtx(wim, him, "tree", fillfn = linear({from: "darkslategray", to:"darkblue"}));

function star(r1, r2, n) {
  let ra2xy = (r, a) => [r*Math.cos(a), r*Math.sin(a)]
  let a = 2*Math.PI/n;
  let p = new Path2D();
  p.moveTo(r1, 0);
  for (let i = 0; i <= n; i++) {
    let [x, y] = ra2xy(r2, i*a);
    p.lineTo(x, y);
    let [x2, y2] = ra2xy(r1, a/2+i*a);
    p.lineTo(x2, y2);
  }
  p.closePath();
  return p;
}

function drawStar(ctx, v, c, starFn) {
  if (starFn === undefined) {
    return;
  }
  ctx.save();
  ctx.translate(v.x, v.y);
  drawPath(starFn(), ctx, c);
  ctx.restore();
}

function randInt(f, t) {
  return f + Math.floor(Math.random()*(t - f + 1));
}

function chain(r) {
  const p = new Path2D();
  p.arc(0, 0, r, 0, 2*Math.PI);
  return p;
}


function drawChain(ctx, v) {
  let n = 5;
  const cfn = ()=>`hsl(${randInt(0, 360)}, ${randInt(70, 80)}%, ${randInt(80, 90)}%)`;
  let dr = 40;
  ctx.save();
  ctx.translate(v.x , v.y);
  for (let i = 1; i <= n; i++) {
    ctx.save();
    ctx.translate(randInt(-50, 50), 0);
    ctx.lineWidth = randInt(1, 5);
    ctx.strokeStyle = cfn();
    ctx.stroke(chain(i * dr));
    ctx.restore();
  }
  ctx.restore();
}


function tinted(tintFn, gradFn) {
  return (ctx) => {
      let t = tintFn();
      let gradCfg = {
        from: `hsl(${t}, ${randInt(80,95)}%, ${randInt(60, 80)}%)`, 
        to: `hsl(${t}, ${randInt(70, 80)}%, ${randInt(20, 40)}%)`,
        end: 0.3 + Math.random()*0.7
      };

      return gradFn(gradCfg)(ctx);
      }
}

function drawSnow(ctx, {
  col = ()=>`hsla(30, 90%, ${randInt(90, 100)}%, 0.2)`, 
  r= () => randInt(1, 3), 
  cnt = 100}) {
  ctx.save();
  for (let i = 0; i < cnt; i++) {
    ctx.fillStyle = col(); 
    let [x, y] = [randInt(0, wim), randInt(0, him)];
    ctx.arc(x, y, r(), 0, 2*Math.PI);
    ctx.closePath();
    ctx.fill();
  }
  ctx.restore();
  
}

drawTrees(ctx, {
  treePath: tp,
  treesCount: cnt,
  gradientFn: tinted(()=>randInt(80, 150), linear),
  outclip: (ctx) => drawStar(ctx, vs[0], "yellow", () => star(10, 30, randInt(4, 10))),
  inclip: (ctx) => {
    drawChain(ctx, vs[0], colorGenerator({sat: 70, lig: 70}));
    drawBaubles(ctx, bCfg);
    }
  });

drawSnow(ctx, {});