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