Experience/[Javascript] JS 30

[Javascript] 08. Fun with HTML5 Canvas

winCow 2021. 4. 20. 00:42

1. 배경

마우스를 누른 상태로 움직이면 이를 따라 색칠이 되는 캔버스를 구현하고자 한다. 마우스가 이동됨에 따라 크기와 색깔이 변한다.

 

 

2. HTML, CSS

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>HTML5 Canvas</title>
</head>
<body>
<canvas id="draw" width="800" height="800"></canvas>
<style>
  html, body {
    margin: 0;
  }
</style>

</body>
</html>

 

 

3. Javascript

<script>
  const canvas = document.querySelector('#draw');
  const ctx = canvas.getContext('2d');
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  ctx.strokeStyle = "#BADA55"
  ctx.lineJoin = 'round';
  ctx.lineCap = 'round';
  ctx.lineWidth = 100;
  ctx.globalCompositeOperation = 'multiply';
</script>

먼저 querySelector를 이용해 HTML의 canvas 태그를 선택하고, canvas 변수에 할당한다. 또, 여기에 getContext 매소드를 사용해 드로잉 컨텍스트를 반환한다. 매개변수를 2d로 하여 2차원 렌더링 컨텍스트를 반환하고, 이를 변수 ctx에 할당한다.

여기부터는 초기 설정과 같다. 캔버스의 넓이를 window.innerWidth, window.innerHeight 를 통해 윈도우의 크기에 맞게 설정한다. strokeStyle로 도형의 윤곽선 색을 설정하고, linecap으로 선의 모양을 둥글게, linejoin으로 도형의 모서리를 둥글게 연결되게 하였다. 다만, 도형을 그리는 기능을 제공하지 않으므로 linejoin은 확인하기는 어려워 보인다. lineWidth로는 현재 선의 두께를 설정할 수 있다. globalCompositeOperation는 선들이 겹칠 때 겹치는 부분을 어떻게 표현할 것인지에 대한 것으로, multiply로 설정하면 겹치는 부분의 색깔이 섞이고, 최종적으로는 검정색이 된다.

 

  let isDrawing = false;
  let lastX = 0;
  let lastY = 0;
  let hue = 0;
  let direction = true;

  function draw(e) {
    if (!isDrawing) return;
    console.log(e);
    ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`;
    ctx.beginPath();
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(e.offsetX, e.offsetY);
    ctx.stroke();
    [lastX, lastY] = [e.offsetX, e.offsetY];

    hue++;
    if (hue >= 360) {
      hue = 0;
    }
    if (ctx.lineWidth >= 100 || ctx.lineWidth <= 1) {
      direction = !direction;
    }

    if (direction) {
      ctx.lineWidth++;
    } else {
      ctx.lineWidth--;
    }

  }
    
  canvas.addEventListener('mousemove', draw);
  canvas.addEventListener('mouseup', () => isDrawing = false);
  canvas.addEventListener('mouseout', () => isDrawing = false);

본격적으로 draw 함수를 정의하는 과정이다. 기능별로 코드를 쪼개서 확인해 보면 다음과 같다.

 

  let isDrawing = false;

  function draw(e) {
    if (!isDrawing) return;
    console.log(e);
    ctx.stroke();
  }

  canvas.addEventListener('mousedown', (e) => {
    isDrawing = true;
    [lastX, lastY] = [e.offsetX, e.offsetY];
  });
  canvas.addEventListener('mousemove', draw);
  canvas.addEventListener('mouseup', () => isDrawing = false);
  canvas.addEventListener('mouseout', () => isDrawing = false);

캔버스에 이벤트 리스너를 걸고, 마우스가 움직일 때 draw 함수가 실행되도록 하였다. isDrawing 단순히 함수를 실행하고 종료하는데 관여하기 위해 정의되었다. isDrawing을 기본 false로 설정하였으므로, if (!isDrawing) return; 코드는 isDrawing이 아닐 때(true) 함수를 그대로 종료한다는 의미를 담게 된다. 이를 기반으로, 이벤트 리스너를 추가하여, 마우스 다운 이벤트가 일어날 때는 isDrawing이 true 값을 가지게 되고, if (!isDrawing) return; 코드의 !isDrawing이 false가 되므로 실행되지 않고, 이어서 draw 함수의 나머지 부분이 실행될 것이다. 또, 마우스 업 이벤트가 일어날 때, 즉 마우스를 뗄 때는 isDrawing이 true로 변경되므로 draw 함수의 실행이 중단될 것이다. stroke() 매소드는 윤곽선을 이용하여 도형을 그리는 매소드이므로, draw 함수가 실행된다면 마우스를 따라 그림이 그려지기 시작할 것이다. mouseout은 이벤트 요소에서 마우스 포인터가 떠날 때 발생하는데, 여기서는 캔버스가 이벤트 요소이므로 캔버스를 떠날 때가 된다. 이러한 일련의 과정을 통해, 마우스를 누르지 않은 상황에서 마우스 포인터를 따라 계속 그림이 진행되는 것을 방지할 수 있다. 또한, 마우스 다운 이벤트가 발생할 때는, 해당 이벤트의 속성인 offsetX와 offsetY값을 받아와 각각 변수에 저장하도록 하였다.

 

  let hue = 0;

  function draw(e) {
    if (!isDrawing) return;
    ctx.strokeStyle = `hsl(${hue}, 100%, 50%)`;

    hue++;
    if (hue >= 360) {
      hue = 0;
    }

draw 함수가 실행되고 있는 상황에서, 앞서 정의한 ctx의 strokeStyle을 변경되게 하였다. hsl(0, 100%, 50%)과 같은 표기 방식으로 strokeStyle 값을 설정하는데, ${hue} 값은 1씩 커지도록 hue++를 입력했다. 이를 통해 함수가 실행될 때마다, 마우스를 누른 채 움직이는 이벤트가 전달될 때마다 ${hue} 값은 1씩 커질 것이고, 360을 넘는 시점에서 0으로 초기화될 것이다. 이를 통해, 마우스를 누른 채 움직일 때마다, 캔버스에 그려지는 선의 색깔이 0부터 360에 지정된 361개의 색깔로 순차적으로 변경될 것이다.

 

  let lastX = 0;
  let lastY = 0;


  function draw(e) {
    if (!isDrawing) return;
    ctx.beginPath();
    ctx.moveTo(lastX, lastY);
    ctx.lineTo(e.offsetX, e.offsetY);
    [lastX, lastY] = [e.offsetX, e.offsetY];    
  }
    
  canvas.addEventListener('mousedown', (e) => {
    isDrawing = true;
    [lastX, lastY] = [e.offsetX, e.offsetY];
  });

마찬가지로 draw 함수가 실행되고 있는 상태에서, beginPath 매소드를 이용하면 이벤트가 발생할 때마다 경로가 초기화된다. 앞서, 마우스의 움직임을 따라 도형이 그려지고, 색깔이 변경하도록 설정했는데, 경로가 초기화되지 않는다면 마우스를 누르기 시작한 때부터의 모든 경로에 변화가 적용된다. 이를테면, 빨간색으로 그리기 시작하여 파란색으로 마무리되었다면, 시작점은 빨간색과 파란색이 섞인 색깔이 보일 것이다. 경로가 초기화되지 않았기 때문에, 시작점에는 시작한 때의 색깔과 마무리 할 때의 색깔이 동시에 나타나게 되는 것이다.

moveTo 매소드는 펜을 x, y로 지정된 좌표로 옮기는 역할을 한다. beginPath 매소드로 경로를 초기화했을 때는 moveTo와 lineTo를 사용하는 것이 좋은데, 이들를 사용하지 않을 경우에는 beginPath로 인해 매 순간 경로가 초기화되면서 연속적인 선이 아니라 점이 그려진다. 이러한 점들 중 두 개를 a, b라고 했을 때, moveTo는 현재 펜을 종이에서 뗀 후 a에서 b로 옮겨준다고 이해할 수 있고, lineTo는 a와 b를 연결해 준다고 이해할 수 있다. 즉, ctx.moveTo(lastX, lastY)는, 이를테면 aX, aY 좌표에 있는 펜을 bX, bY로 이동시켜주는 것이다.

전체적인 흐름을 보자면, (aX, aY) 지점에서 마우스 다운 이벤트가 전달되었을 때, e.offsetX의 값은 aX, e.offsetY의 값은 aY가 되며, 각각 lastX, lastY에 할당된다. 이후, 마우스 무브 이벤트가 전달되었을 때, draw 함수가 실행되어 (bX, bY) 지점으로 이동되었다고 하자. 그렇다면 moveTo의 매개변수인 lastX, lastY에는 기존에 할당된 aX, aY가 여전히 남아 있을 것이고, lineTo의 매개변수인 e.offsetX, e.offsetY에는 새롭게 bX, bY가 할당될 것이다. 이후, draw 함수 내에서 다시 lastX, lastY에 e.offsetX, e.offsetY가 할당되므로, lastX, lastY는 bX, bY로 바뀌게 될 것이다. 이러한 과정이 연속적으로 일어나면서 선을 긋게 되는 것이다.

 

  let direction = true;
  
  function draw(e) {
    if (ctx.lineWidth >= 100 || ctx.lineWidth <= 1) {
      direction = !direction;
    }

    if (direction) {
      ctx.lineWidth++;
    } else {
      ctx.lineWidth--;
    }
  }

마지막으로, 선의 굵기가 변하도록 하였다. 먼저 첫 번째 if문에서 ctx.lineWidth가 1이하 혹은 100이상이 되는 경우에 direction의 참, 거짓을 뒤집어 주도록 하였다. 두 번째 if문은 direction 값이 true이면 lineWidth를 1씩 더해 주고, false이면 1씩 빼 주도록 하였다. 위에서, lineWidth의 기본값을 100, direction의 기본값을 true로 설정하였으므로, 첫 번째 if문을 통해 direction의 값은 false가 되고 이것이 두 번째 if문으로 전달되어 else에 의해 lineWidth가 1씩 줄어든다. 이것이 반복되어 lineWidth가 1이 되는 순간 direction의 값이 바뀌어 다시 lineWidth가 1씩 늘어나게 된다.