Spis treści

Jak zrobić świąteczną choinkę - Canvas API

Czego się nauczysz

  • jak używać HTML Canvas API do rysowania
  • jak używać Path2D do definiowania kształtów
  • jak rysować jedynie wewnątrz określonego kształtu (ścieżki) Path2D
  • jak stworzyć nieskończony generator kolorów
  • jak używać obiektów JavaScript jako obiektów konfigurujących działanie funkcji
  • jak używać funkcji ze strzałką (arrow functions) jako sposobu dostarczania danych do funkcji (Supplier)
  • jak refaktoryzować kod podczas pisania po to, aby umożliwić tworzenie coraz bardziej złożonych rysunków

Przygotowanie

Zacznijmy od dodania do strony dwóch elementów: canvas (“płótno” do rysowania; tekst wewnątrz niego będzie wyświetlony przez przeglądarkę wtedy, gdy nie obsługuje ona tego elementu) oraz script, który będzie zawierał zmienną reprezentującą canvas oraz kod rysujący:

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

Rysowanie tła

Wewnątrz elementu script można umieścić poniższy kod:

 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();

Kod ten:

  • odnajduje element canvas o identyfikatorze tree w dokumencie
  • tworzy kontekst rysowania
  • ustawia kolor wypełnienia (to jeden z elementów stanu kontekstu) na navy
  • rysuje wypełniony prostokąt, którego lewy górny róg znajduje się na pozycji (0,0), a prawy dolny na pozycji (t.width, t.height)

Oto canvas:

Rysowanie wielokąta

Następnym krokiem jest narysowanie choinki. Będzie to zwykły trójkąt równoramienny. Przyjmiemy, że

  • górny wierzchołek choinki będzie w 0.9 wysokości, na środku
  • dolne wierzchołki utworzą podstawę o długości równej wysokości choinki; będą na 0.1 wysokości
  • wyznaczenie położeń wierzchołków wymaga znajomości wymiarów, więc zrefaktoruję funckję basicCtx tak, aby przyjmowała jako argumenty wywołania wymiary i id canvasu, oraz aby zwracała kontekst ctx (chcę go przekazać do funkcji rysującej)
 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");

Następnie obliczam współrzędne wierzchołków. Funkcja Vertex tworzy nowy wierzchołek.

Do narysowania i wypełnienia wielokąta złożonego z wierzchołków użyłam funkcji drawVertices, która przyjmuje:

  • kontekst rysowania
  • kolor
  • listę wierzchołków i korzysta z funkcji kontekstu beginPath, moveTo i lineTo, aby utworzyć ścieżkę (wypełnianą wywołaniem fill):
 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

Kolejny krok to użycie gradientu zamiast zwykłego koloru. W wywołaniu drawVertices użyję utworzonego gradientu liniowego: zacznie się on w wierzchołka vtop a zakończy w połowie podstawy choinki. Będzie zawierał jedynie dwa kolory:

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]);

Warto wydzielić kod tworzący gradient do osobnej funkcji, której przekażemy dane potrzbne do stworzenia gradientu: kolory: początkowy i końcowy oraz punkty na linii gradientu (przyjmujące wartości od 0 do 1), między którymi rozciąga się gradient.

Funkcja linear przyjmuje obiekt konfigurujący gradient, jednak nie zwraca jeszcze gradientu - do jego utworzenia potrzebny jest bowiem kontekst rysowania - lecz jednoargumentową funkcję, która utworzy gradient po przekazaniu jej właściwego kontekstu:

 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]);

Ścieżka

Jednym ze sposobów tworzenia kształtów jest użycie interfejsu Path2D, który pozwala nie tylko na tworzenie wielokątów, lecz także na rysowanie dowolnych ścieżek SVG.

Możemy zmienić kod rysujący wierzchołki drawVertices tak, aby rysował podaną ścieżkę na podany kolor, a samo tworzenie ścieżki będzie osobną funkcją:

  • funkcja createVerticesPath przyjmuje listę wierzchołków (Vertex) i zwraca zamkniętą ścieżkę; dzięki niej tworzę treePath - ścieżkę reprezentującą drzewko
  • funkcja drawVertices została zmieniona na drawPath ponieważ wypełnia podaną ścieżkę zadanym kolorze
 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);

Łączenie ścieżek

Czasami chcę utworzyć ścieżkę złożoną z wielu podścieżek. Przyda mi się to wówczas, gdy chcę narysować ją jednym “pociągnięciem pędzla”.

Do łączenia ścieżek służy metoda addPath.

Na poniższym przykładzie

  • ścieżki treePath używam do narysowania trójkąta - choinki
  • rysuję ścieżkę z bombkami: funkcja createBaubles zwraca tablicę ze ścieżkami, a joinPaths - jedną ścieżkę złożoną z podścieżek - bombek
 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), ctx4, "red");

A oto rysunek:

Ścieżka przycięcia

Cóź, trzeba te losowo rysowane bombki ujarzmić: spróbuję narysować je wewnątrz ścieżki treePath. Przyda się do tego metoda clip w interfejsie CanvasRenderingContext2D.

Dzięki niej wszystko to, co zostanie narysowane na kontekście, na którym wywołano tę metodę, nie będzie wykraczało poza bieżącą ścieżkę (gdy nie podano argumentów) bądź poza podaną ścieżkę.

Używam treePath jako ścieżki przycinającej i używam jej na głównym kontekście; rysowanie losowych bombek będzie ograniczone do kształtu drzewka:

Oto kod:

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

A to choinka z bombkami:

Kolory bombek

Kolor “red” jest zbyt intensywny i razi w oczy. Chcę, żeby każda bombka miała nieco inny, mniej intensywny kolor.

Spróbuję rysować ścieżki w pętli, każdej przyporządkowując kolejny kolor pochodzący z generatora kolorów:

 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);
}

Generator używa różnych wartości hue od 0 do 360, a saturation oraz lightness (w modelu HSL) ustawia zawsze takie same, jak zostały podane w konstruktorze. Generuje kolory w nieskończoność.

Rysunek:

Gradient w tle

Aby użyć gradientu w tle obrazka, zmienię funckję basicCtx. Obecnie ma ona na stałe wpisany kolo tła, a wygląda ona tak:

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;
}

Przekażę jej funkcję tworzącą styl wypełnienia fillfn - na przykład funkcję zwracającą zwykły kolor bądź funckję zwraconą przez linear i tworzącą gradient, a jej domyślną wartością będzie funkcja zwracająca obecnie rysowany kolor:

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;
}

Dzięki temu mogę łatwo utworzyć i przekazać gradienty dla choinki oraz dla tła:

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 ctx6 = basicCtx(wi, hi, "t6", fillfn=bggrad);
drawPath(treePath, ctx6, gp(ctx6));

Dużo choinek!

Więcej choinek! Chcę narysować dużo choinek, żeby na blogu zrobiło się naprawdę świątecznie. Spróbujmy postawić kilka choinek obok siebie.

Utworzę funkcję drawTrees, która otrzyma kontekst oraz obiekt konfigurujący sposób rysowania. Będzie zawierał:

  • gradientFn - funkcję, która zwróci wypełnienie drzewka (gradient albo kolor)
  • outclip - funkcję, która rysuje coś na zewnątrz obszaru drzewka (przed wywołaniem clip)
  • inclip - funkcję, która rysuje coś wewnątrz obszaru drzewka (po wywołaniu clip)
  • treesCount - liczbę drzewek
  • treePath - ścieżka do rysowania drzewka
 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();
  }
}

Funkcja rysowania bombek będzie również sparametryzowana. Obiekt konfigurujący rysowanie bombek zawiera:

  • count - parametr określający liczbę bombek
  • colFn - funkcję zwracającą kolor następnej bombki
  • rFn - funkcję zwracającą rozmiar bombki (promień koła)
 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);
    }
}

Ostatnim krokiem jest utworzenie obiektu konfigurującego rysowanie bombek oraz wywołanie drawTrees.

Do przekształcenia nieskończonego generatora zwracanego przez colorGenerator w funkcję będącą providerem koloru służy prosta funkcja callable.

 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)
});

Dodatki

Na szczycie każdej choinki można dodać gwiazdkę, choinkom można założyć łańcuchy, a z nieba powinien prószyć biały śnieg.

Wesołych Świąt :)

 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, {});