7️⃣

07장 Canvas를 사용한 동적 데이터 시각화

7.1 Canvas란?

7.1.1 Canvas란?

Canvas는 웹 페이지에서 그림을 그리거나 그래픽을 렌더링할 수 있는 HTML5 요소이다. 기본적으로 캔버스는 비트맵 이미지와 유사한 2D 렌더링 컨텍스트를 제공하며, 개발자가 자바스크립트를 사용하여 동적으로 그래픽을 생성하고 조작할 수 있게 한다. Canvas는 어떠한 특정 그래픽이나 스타일을 가지고 있지 않으며, 단지 그래픽을 그릴 수 있는 공간을 제공하는데, 이 공간을 채우기 위해서는 JavaScript를 사용하여 2D 또는 WebGL 컨텍스트에 접근하고 그 위에 그림을 그리는 메서드를 사용해야 한다.
즉, 데이터 시각화 및 사용자 인터페이스를 동적으로 만들고 그래픽 작업을 해야 하는 상황에서 주로 사용된다.
 

7.2 React에서 Canvas 사용하기

7.2.1 Canvas API의 주요 메서드

Canvas를 React에서 사용할 때는 기본적인 React 훅과 Canvas API를 결합하여 동적인 그래픽을 만들 수 있다.
Canvas는 이름 그대로, 개발자가 원하는 시각화를 캔버스에 그림 그리듯이 구현할 수 있도록 다양한 메서드를 제공하는데, API의 종류가 매우 광범위 하다. 마치 화가가 도화지에 그림을 그리듯이 개발자가 시각화 하고 싶은 이미지, 그림을 코드로 그릴 수 있게 관련 도구들을 제공한다.
 
MDN Web Docs의 Canvas API 튜토리얼에서는 아래와 같은 주요 주제들을 다루고 있다.
  • Basic usage: 캔버스의 기본적인 사용법
  • Drawing shapes: 다양한 도형을 그리는 방법
  • Applying styles and colors: 스타일과 색상을 적용하는 방법
  • Drawing text: 텍스트를 그리는 방법
  • Using images: 이미지를 캔버스에 그리는 방법
  • Transformations: 변환(회전, 스케일링 등)을 적용하는 방법
  • Compositing and clipping: 합성 및 클리핑 기법
  • Basic animations: 기본적인 애니메이션을 구현하는 방법
  • Advanced animations: 고급 애니메이션 기법
  • Pixel manipulation: 픽셀 단위로 캔버스를 조작하는 방법
  • Optimizing the canvas: 캔버스 성능 최적화 방법
  • Finale: 캔버스의 전반적인 마무리 작업 및 팁
 
Canvas API는 각 기능이 다루는 영역이 매우 다양하고, 시각적 스타일이나 컨셉이 크게 다르기 때문에 모든 API를 다루는 것은 어렵다. 각 API는 특정한 그래픽 작업에 맞춰 설계되어 있으며, 색상 처리, 도형 그리기, 애니메이션, 이미지 조작 등 여러 가지 측면에서 서로 다른 기능과 접근 방식을 요구한다. Canvas API의 각 개념과 메서드는 아래 MDN Web Docs의 Canvas API 튜토리얼에서 자세히 확인할 수 있다.
 

7.2.2 깃허브 Canvas 코드

Canvas API들을 살펴보기 전에 React에 Canvas 코드를 적용해보자.
React에서 Canvas를 사용하기 위해서는 먼저 Canvas API에 대한 기본적인 이해가 필요하다. Canvas API는 HTML5에서 제공하는 2D 그래픽을 위한 인터페이스로, 그리기 영역(캔버스)에 도형, 텍스트, 이미지 등을 그릴 수 있게 한다.
React 컴포넌트 내에서 Canvas를 사용하려면, <canvas> 태그를 JSX로 작성하고, 이 태그에 접근하여 그래픽을 그릴 수 있는 2D 컨텍스트를 가져와야 한다. React에서 Canvas를 사용하기 전, 앞으로 나올 Canvas 코드는 아래 깃허브 링크에서 볼 수 있다.
 
파일 구조는 다음과 같으며 Canvas 챕터의 코드는 src\pages\canvas 폴더에 있다.
notion imagenotion image

7.2.3 Canvas 기본 코드

  • 기본 설정
useRef훅은 React에서 특정 DOM 요소에 접근하기 위해 사용된다. canvasRef를 통해 <canvas> 요소에 접근할 수 있게 한다.
useEffect 훅은 컴포넌트가 마운트된 후에 실행되는 사이드 이펙트를 처리한다. 이 경우, 캔버스에 그림을 그리기 위한 초기 설정을 여기에 포함시킨다. []는 빈 배열이며, useEffect가 컴포넌트가 처음 마운트될 때만 실행되도록 설정한다. 이는 캔버스 초기화를 한 번만 수행하기 위해 사용된다.
getContext('2d')는 캔버스의 2D 렌더링 컨텍스트를 반환한다. 이 컨텍스트는 캔버스에서 그래픽을 그리기 위해 필요한 모든 메서드와 속성을 제공한다.
 
  • 캔버스 크기 설정
canvas.widthcanvas.height는 HTML <canvas> 요소의 크기를 픽셀 단위로 설정하는 속성이다. 이 값은 캔버스가 그래픽을 그릴 때의 실제 해상도를 결정하며, 캔버스의 도형과 그래픽이 이 크기를 기준으로 그려진다. canvas.width를 500, canvas.height를 300으로 설정하면 캔버스의 너비는 500px, 높이는 300px이 된다. 해당 크기는 화면에서 렌더링되는 캔버스의 사이즈와 동일하며, 크기 내에서 그래픽을 그릴 수 있다.
  • 배경 설정
Canvas는 투명한 배경을 기본으로 하기 때문에 배경색을 설정하려면 캔버스에 직접 사각형을 그려서 배경을 채워야 한다. 배경을 설정하기 위해서는 fillStylefillRect 메서드를 사용한다.
 
  • return
<canvas> 요소를 canvasRef에 연결하여 자바스크립트 코드에서 해당 요소에 직접 접근할 수 있게 한다.
import { useRef, useEffect } from 'react'; const Canvas = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 캔버스의 크기 설정 canvas.width = 500; canvas.height = 300; // 파란색 배경의 직사각형 그리기 context.fillStyle = '#0000FF'; context.fillRect(0, 0, canvas.width, canvas.height); // 여기에 캔버스 그리는 코드 작성하기 }, []); return <canvas ref={canvasRef} />; }; export default Canvas;
 

7.3 Canvas API 실습

Canvas API에는 텍스트 디자인, 합성 및 클리핑, 애니메이션 등 다양한 카테고리가 있기 때문에 해당 챕터에서는 공식 문서의 모든 API 튜토리얼을 다루지 않고, 마지막에 나올 [7.4 Canvas 데이터 시각화 예제 - 일출 일몰 그래프]에서 사용되는 메서드들에 중점을 두어 실습을 진행할 것이다.
 

7.3.1 useRef와 useEffect

useRef는 React에서 DOM 요소나 클래스 인스턴스에 대한 참조를 유지하는 데 사용된다. Canvas에서는 canvasRef를 통해 <canvas> 요소에 직접 접근하고 조작할 수 있도록 한다.
useEffect는 컴포넌트가 마운트되거나 업데이트될 때 특정 작업을 수행하기 위해 사용된다. useEffect 훅 안에 그림을 그리는 코드를 작성한다.
canvas.getContext('2d')는 캔버스의 2D 렌더링 컨텍스트를 반환한다. 이 컨텍스트는 실제로 캔버스에 그림을 그리는 데 필요한 다양한 메서드와 속성들을 제공한다.
import { useRef, useEffect } from 'react'; const Canvas = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); }, []); return <canvas ref={canvasRef} />; }; export default Canvas;
 

7.3.2 width와 height

canvas.widthcanvas.height는 캔버스의 가로 및 세로 크기를 설정한다. 이 값들은 픽셀 단위로 측정되며 Canvas를 사용하는 컴포넌트에 props로 전달해도 되고 Canvas 컴포넌트 자체에서 설정해도 된다.
  1. props를 받아 width와 height 설정하기
import { useRef, useEffect } from 'react'; const Canvas = ({ width, height }) => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 부모 컴포넌트에서 받은 크기로 설정 canvas.width = width; canvas.height = height; }, [width, height]); return <canvas ref={canvasRef} />; }; export default Canvas;
 
  1. 컴포넌트 내부에서 width와 height 설정하기
import { useRef, useEffect } from 'react'; const Canvas = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 컴포넌트 내부에서 고정된 크기로 설정 canvas.width = 500; canvas.height = 300; }, []); return <canvas ref={canvasRef} />; }; export default Canvas;
 

7.3.3 fillStyle와 fillRect()

fillStyle로 색상을 설정하는데, 설정된 색상은 캔버스에 도형을 그릴 때 채우기 작업에 사용된다. 예를 들어, context.fillStyle = '#0000FF';는 Canvas 영역을 파란색으로 채운다. 16진수 색상 코드가 아닌 context.fillStyle = 'blue'; 처럼 단어로 색상을 표현할 수도 있다.
context.fillRect(x, y, width, height)는 사각형을 채울 때 사용한다. x, y는 사각형의 시작 좌표이고, width와 height는 사각형의 너비와 높이다.
notion imagenotion image
import { useRef, useEffect } from 'react'; const FillRect = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 캔버스의 크기 설정 canvas.width = 500; canvas.height = 300; // 파란색 배경의 직사각형 그리기 context.fillStyle = '#0000FF'; context.fillRect(0, 0, canvas.width, canvas.height); }, []); return <canvas ref={canvasRef} />; }; export default FillRect;
 

7.3.4 beginPath()

context.beginPath() 메서드는 새로운 경로를 시작하는 데 사용된다. 이전에 그렸던 경로를 초기화하고 새로운 경로를 시작할 때 호출해야 하며, 경로는 도형이나 선을 그릴 때 사용되는 일련의 점들로 구성된다.
beginPath()를 호출한 후에는 도형이나 선을 그리기 위해 moveTo(), lineTo(), arc() 등의 메서드를 사용할 수 있다. beginPath()는 각각의 도형을 그리기 시작할 때 호출하여 도형 간 경로가 겹치지 않도록 한다.
arc()는 원 또는 호를 그릴 때 사용한다. 원의 중심 좌표(x, y)와 반지름(radius)을 지정하고, 시작 각도(startAngle)와 끝 각도(endAngle)를 라디안 단위로 설정하여 도형을 그린다. 예를 들어, context.arc(100, 100, 50, 0, 2 * Math.PI)는 중심이 (100, 100)이고 반지름이 50px인 완전한 원을 그린다.
도형을 그린 후에는 fill()을 사용하여 현재 경로를 채울 수 있다. fillStyle로 설정된 색상으로 경로의 내부를 채우는 작업을 수행하는데 주로 arc()fillRect()와 함께 사용된다.
아래 코드에서는 beginPath()를 사용하여 복수의 도형을 그리는 방법을 보여준다. 여러 개의 도형을 그릴 때 각각의 도형을 구분하기 위해 beginPath()를 호출하여 새로운 경로를 시작하고, 각 도형을 개별적으로 그린다.
notion imagenotion image
import { useRef, useEffect } from 'react'; const BeginPath = (props) => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 캔버스의 크기 설정 canvas.width = 500; canvas.height = 300; // 파란색 배경의 직사각형 그리기 context.fillStyle = 'blue'; context.fillRect(0, 0, context.canvas.width, context.canvas.height); // 첫 번째 사각형 그리기 context.beginPath(); // 새 경로 시작 context.fillStyle = 'red'; context.fillRect(10, 10, 100, 50); // 두 번째 사각형 그리기 context.beginPath(); // 새 경로 시작 context.fillStyle = 'white'; context.fillRect(150, 10, 100, 50); // 원 그리기 context.beginPath(); // 새 경로 시작 context.fillStyle = 'green'; context.arc(150, 150, 50, 0, Math.PI * 2); context.fill(); }, []); return <canvas ref={canvasRef} {...props} />; }; export default BeginPath;
 

7.3.5 moveTo(x, y)와 lineTo(x, y)

context.moveTo(x, y)는 Canvas의 2D 컨텍스트에서 그리기 시작할 좌표를 설정하고, lineTo(x, y)는 현재 위치에서 새로운 좌표까지 선을 그린다. moveTo()는 선을 그리지 않고 좌표만 이동시키며, lineTo()는 선을 그리기 시작하는 메서드이다.
 

7.3.6 stroke()와 strokeStyle

lineTo()로 선을 그리는 작업을 마쳤다면 context.stroke()를 호출하여 화면에 선을 그린다. stroke()lineTo()로 정의한 경로를 실제로 렌더링하는 메서드로, 경로가 화면에 보이도록 선을 그린다.
이때, 선의 색상은 context.strokeStyle 속성을 통해 설정할 수 있다. strokeStylestroke() 메서드로 그린 선의 색상을 지정한다.
 
notion imagenotion image
import { useRef, useEffect } from 'react'; const Triangle = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 캔버스의 크기 설정 canvas.width = 500; canvas.height = 300; // 파란색 배경의 직사각형 그리기 context.fillStyle = '#0000FF'; context.fillRect(0, 0, canvas.width, canvas.height); // 선의 색상과 두께 설정 context.strokeStyle = '#FFFFFF'; // 흰색 선 context.lineWidth = 5; // 새로운 경로 시작 context.beginPath(); // 삼각형 그리기 context.moveTo(100, 100); // 시작점 (100, 100) context.lineTo(200, 50); // (200, 50)까지 선 그리기 context.lineTo(300, 100); // (300, 100)까지 선 그리기 context.lineTo(100, 100); // 시작점으로 다시 선 그리기 // 경로를 화면에 그리기 context.stroke(); }, []); return <canvas ref={canvasRef} />; }; export default Triangle;
 
 

7.3.7 lineWidth

lineWidth 속성은 선의 두께를 픽셀 단위로 설정한다. 두꺼운 선이나 얇은 선을 자유롭게 지정할 수 있다.
context.lineWidth = 5; // 두께를 5px로 지정
 

7.3.8 JavaScript 추가 예제

아래 메서드들은 Canvas API가 아닌 JavaScript에 내장된 메서드지만 [7.4 Canvas 데이터 시각화 예제 - 일출 일몰 그래프]에서 사용되기 때문에 한 번 짚고 넘어가 보자.
Math.sin() 함수는 주어진 각도(라디안)의 사인 값을 반환한다. 파형을 그리거나 주기적인 그래픽을 만들 때 사용된다.
Math.PI는 원주율(π)을 나타내며, 2 * Math.PI는 원 전체를 의미하고, 반원을 그리려면 Math.PI만 사용하면 된다.
rgba()는 색상과 함께 투명도를 지정할 수 있는 색상 표현 방식이다. 이 함수는 RGB 값을 정의한 후 알파값을 추가로 지정하여 색상의 투명도를 설정하는데 이를 통해 색상을 반투명하게 표시할 수 있다.
notion imagenotion image
import { useRef, useEffect } from 'react'; const Sin = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // 캔버스의 크기 설정 canvas.width = 500; canvas.height = 300; // 파란색 배경의 직사각형 그리기 context.fillStyle = '#0000FF'; context.fillRect(0, 0, canvas.width, canvas.height); // 선의 색상과 두께 설정 context.strokeStyle = '#FFFFFF'; // 흰색 선 context.lineWidth = 5; // 파란색 사인파 그리기 context.beginPath(); for (let x = 0; x < context.canvas.width; x++) { const y = 100 + Math.sin(x * 0.05) * 50; // 사인파 생성 if (x === 0) { context.moveTo(x, y); // 시작점 설정 } else { context.lineTo(x, y); // 선 그리기 } } context.strokeStyle = 'rgba(255, 255, 255, 1)'; // 하얀색 선 context.stroke(); // 녹색 사각형 그리기 context.fillStyle = 'rgba(0, 255, 0, 0.5)'; // 반투명한 녹색 context.fillRect(50, 150, 100, 100); // (x, y, width, height) // 빨간색 원 그리기 context.beginPath(); context.arc(300, 200, 50, 0, 2 * Math.PI); // (x, y, radius, startAngle, endAngle) context.fillStyle = 'rgba(255, 0, 0, 0.7)'; // 반투명한 빨간색 context.fill(); // 노란색 선 그리기 context.beginPath(); context.moveTo(50, 250); // 시작점 설정 context.lineTo(350, 250); // 끝점 설정 context.strokeStyle = 'rgba(255, 255, 0, 1)'; // 노란색 선 context.lineWidth = 5; // 선 두께 설정 context.stroke(); }, []); return <canvas ref={canvasRef} />; }; export default Sin;
 

7.4 Canvas 데이터 시각화 예제 - 일출 일몰 그래프

이제 Canvas를 이용해 일출, 일몰 데이터 시각화를 해보자. 이번 예제에서는 정형화된 그래프를 그대로 따라하기 보다는, 원하는 디자인을 어떻게 구현할 수 있는지에 초점을 맞출 것이다.
 

7.4.1 예제 사이트 확인하기

아래의 '웨더뉴스' 사이트에서는 시간별 일출과 일몰 정보를 확인할 수 있다.
 
개발자 도구(F12)를 통해 확인해보면, 해당 사이트의 일출 및 일몰 데이터 시각화가 Canvas를 사용하여 구현되었음을 알 수 있다. 해당 일출 일몰 데이터 시각화를 구현해보자.
notion imagenotion image
 

7.4.2 예제 조건 정의하기

  • 시간 조절 슬라이드
    • 아래의 슬라이드를 움직이면 시간별로 해(노란 원)가 그래프를 따라 움직인다.
  • x축
    • 이제부터 그래프를 가로지르는 회색 선을 x축이라고 칭하겠다. 이 x축은 해당 날짜의 일출 및 일몰 시간을 나타낸다.
  • 그래프
    • 물결 모양의 그래프는 sin 또는 cos 그래프로 정의할 수 있는데, sin 그래프로 진행할 것이다.
  • 해의 위치와 배경 변화
    • 해가 x축 위에 있을 때는 전체 배경의 색상이 하얀색으로 표시되고, 해가 x축 아래에 있을 때는 x축 아래 배경의 색상이 검은색으로 변경된다.
  • 슬라이더(slider)의 이동에 따른 해와 그래프의 움직임
    • 슬라이더를 오른쪽으로 움직이면 sin 그래프는 왼쪽으로 향하고 해는 y축 방향(위, 아래)으로 움직인다. 슬라이더를 왼쪽으로 움직이면 sin 그래프는 오른쪽으로 향하고 해는 y축 방향(위, 아래)으로 움직인다.
notion imagenotion image
notion imagenotion image
 

7.4.3 구현 목표

이 챕터는 Canvas를 활용하여 개발자가 데이터 시각화를 구현하는 데 익숙해지도록 돕는 것이 목표이기 때문에 실제 일출 및 일몰 데이터를 가져와서 정확한 시각화를 수행하지는 않는다. 그래프의 디자인과 동작을 어떻게 구현할 수 있는지에 초점을 맞추어 다음을 구현해보자.
  • 데이터 시각화 배경 생성
    • 그래프의 기본이 되는 배경을 Canvas로 그린다.
  • 그래프 생성
    • 일출과 일몰을 표현하는 sin 그래프 곡선을 그린다.
  • 움직이는 해 구현
    • 그래프를 따라 움직이는 원(해)을 생성하고, 슬라이더의 움직임에 따라 위치가 변하도록 한다.
  • 배경 색상 변경
    • 해의 위치의에 따라 배경의 색상이 변화하도록 구현한다. 해가 x축 위에 있을 때와 아래에 있을 때 배경이 달라지도록 한다.
  • 슬라이더 연결
    • 슬라이더를 만들어 해의 움직임을 제어할 수 있도록 연결한다.
 

7.4.4 배경 그리기

Canvas의 크기를 화면 가로 크기에 맞게 설정하고 일출, 일몰을 표현할 그래프를 그려보자. 이 단계에서는 캔버스 배경을 두 부분으로 나누고, 중간에 x축을 그려 그래프의 기본 틀을 마련한다.
 
  1. Canvas 크기 설정
Canvas의 가로 크기는 window.innerWidth로 설정하여 화면 크기에 맞추고, 세로 크기는 400px로 고정한다. 이렇게 설정하면 반응형으로 동작하며, 다양한 화면 크기에서도 일관된 그래프를 표시할 수 있다.
하지만 현재 코드에서는 처음 로드될 때만 창의 가로 크기를 반영하므로 창을 로드할 때의 너비로 캔버스가 설정되며, 창 크기가 변경되더라도 캔버스 크기는 변하지 않는다. 만약 창 크기 변경 시에도 캔버스 가로 크기를 동적으로 조정하고 싶다면, resize 이벤트를 처리해야 한다.
import { useRef, useEffect } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); // Canvas 크기를 화면 가로 크기와 400px 높이로 설정 canvas.width = window.innerWidth; canvas.height = 400; }, []); return <canvas ref={canvasRef} />; }; export default SunriseSunset;
 
  1. 배경 그리기
캔버스를 두 부분으로 나누어 상단은 하늘색, 하단은 어두운 색으로 설정한다. 이는 낮과 밤을 구분하는 배경이다. 색상과 선의 두께 등 기상조건 사이트의 일출, 일몰 디자인과 똑같이 하는 과정은 가장 마지막에 수행하겠다.
화면의 세로의 절반(midHeight)을 기준으로 색을 나누고, 각각 fillStylefillRect()를 사용해 배경을 채운다.
notion imagenotion image
import { useRef, useEffect } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; // 배경을 설정: 위쪽은 하늘색, 아래쪽은 어두운 색 const midHeight = canvas.height / 2; // 하늘색 배경 context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); // 어두운 색 배경 context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); }, []); return <canvas ref={canvasRef} />; }; export default SunriseSunset;
 
  1. X축 그리기
X축은 해의 위치가 낮과 밤을 구분하는 기준선이다. 이를 위해 캔버스의 중앙에 하얀색 선을 그린다. strokeStylelineWidth로 선의 색상과 두께를 설정하고, moveTo()lineTo()로 선을 그려보자.
notion imagenotion image
import { useRef, useEffect } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); // X축 그리기 context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); }, []); return <canvas ref={canvasRef} />; }; export default SunriseSunset;
 

7.4.5 sin 그래프 그리기

  1. Sin 그래프 그리기
x축을 기준으로 하는 주기가 1인 sin 그래프를 노란색으로 그린다.
notion imagenotion image
import { useRef, useEffect } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); // Sin 그래프 그리기 context.strokeStyle = '#FFD700'; // 노란색 context.lineWidth = 3; context.beginPath(); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI); context.lineTo(x, y); } context.stroke(); }, []); return <canvas ref={canvasRef} />; }; export default SunriseSunset;
 
  1. sin 그래프가 시작되는 지점에 점(태양)을 추가
startXstartY 변수를 설정하여 그래프의 시작 지점을 계산하고, context.arccontext.fill을 사용해 sin 그래프의 시작 지점에 반지름 5px의 오렌지색 점을 추가한다. 이 점은 해를 표현한 것이다.
notion imagenotion image
import { useRef, useEffect } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); // 그래프 시작 위치 const startX = 0; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI); context.lineTo(x, y); } context.stroke(); // sin 그래프 시작 위치에 점 찍기 context.fillStyle = '#FF4500'; // 오렌지색 context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); }, []); return <canvas ref={canvasRef} />; }; export default SunriseSunset;
 

7.4.6 슬라이더와 해 연결하기

이제 Canvas에서 해의 움직임을 제어할 수 있는 슬라이더를 추가하고, 이 슬라이더 값에 따라 해가 움직이도록 연결해 보자. 또한, 해의 위치에 따라 배경색도 바뀌도록 구현할 것이다.
 
  1. 슬라이더 추가하기
우선, 슬라이더를 추가하고 Canvas의 가로 크기와 동일하게 설정한다.
useState를 사용하여 canvasWidth 상태를 추가하고, 초기값으로 window.innerWidth를 설정해 캔버스와 슬라이더의 가로 크기를 동기화한다.
그리고 <input type="range"> 요소를 추가하여 슬라이더를 구현한다. 이 슬라이더는 아직 해와 연결되지 않지만, 캔버스의 가로 크기와 동일한 폭을 가지며, 최소값은 0, 최대값은 100으로 설정한다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); // canvasWidth 상태 추가 const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); const startX = 0; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI); context.lineTo(x, y); } context.stroke(); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); // canvasWidth 추가 }, [canvasWidth]); return ( <div> <canvas ref={canvasRef} /> {/* 슬라이더 추가 */} <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" /> </div> ); }; export default SunriseSunset;
 
  1. 해 위치 이동하기 (x값)
해의 위치를 오른쪽으로 약간 이동시킨다. 해의 x값을 슬라이더 값에 따라 변화시키면 된다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); // 해의 위치를 약간 오른쪽으로 이동 const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI); context.lineTo(x, y); } context.stroke(); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); }, [canvasWidth]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" /> </div> ); }; export default SunriseSunset;
 
  1. 슬라이더의 값에 따라 sin 그래프 이동하기
슬라이더의 값을 바탕으로 sin 그래프의 위상 이동을 구현하여 그래프가 슬라이더 값에 따라 좌우로 움직이도록 만든다. 이를 통해 사용자가 슬라이더를 조작할 때 sin 그래프가 실시간으로 변화하는 동적 시각화를 구현할 수 있다.
sliderValue는 슬라이더의 현재 값을 저장하고 관리한다. 슬라이더의 값이 변화할 때마다 업데이트되며, sin 그래프의 위상 이동을 계산하는 데 사용된다.
const [sliderValue, setSliderValue] = useState(0); // 슬라이더 값 상태
 
phaseShift는 슬라이더의 값이 0에서 100 사이일 때, 이를 2π 라디안(한 주기) 범위로 변환하여 sin 함수에 적용한다. 슬라이더의 값에 따라 sin 그래프가 좌우로 이동할 때 phaseShift 를 사용하자.
계산 공식은 const phaseShift = (sliderValue * 2 * Math.PI) / 100;이다. 이 공식은 슬라이더 값이 0일 때 위상 이동이 없고, 100일 때는 2π(360도)만큼 이동하도록 조정한다.
const phaseShift = (sliderValue * 2 * Math.PI) / 100;
 
phaseShift는 sin 그래프를 그릴 때 적용된다. 이는 sin 함수에 더해져서 그래프가 슬라이더 값에 따라 좌우로 이동하게 된다. sin 그래프의 각도 계산에서 기존의 2 * Math.PIphaseShift를 더하여 위상 이동을 적용하자.
const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); context.lineTo(x, y); }
 
슬라이더 값이 변경될 때마다 sliderValue 상태가 업데이트되며, useEffect의 의존성 배열에 sliderValue를 추가하여 상태가 변경될 때마다 그래프를 다시 그리도록 하자.
이제 슬라이더가 움직일 때마다 위상 이동이 적용된 새로운 sin 그래프를 그린다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); // 슬라이더의 값 상태 useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); // 슬라이더의 값에 따른 위상 변화 const phaseShift = (sliderValue * 2 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); // 위상 변화 적용 for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); // 위상 변화 적용 context.lineTo(x, y); } context.stroke(); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); // sliderValue 추가 }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> {/* 슬라이더 값 연결 */} <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
 
  1. 해가 x축 위로 가면 아래쪽 배경색 바뀌기
이제 슬라이더를 조작하여 해가 x축 위로 올라가거나 아래로 내려갈 때, 캔버스의 아래쪽 배경색이 변화하도록 구현해보자. 해가 x축 위에 있을 때는 낮을 나타내고, 해가 x축 아래에 있을 때는 밤을 나타내므로 배경색을 동적으로 변경할 필요가 있다.
 
해가 x축 위에 있을 때는 아래쪽 배경을 위쪽 배경과 동일하게 하늘색('#87CEEB')으로 변경하고 해가 x축 아래에 있을 때는 아래쪽 배경은 어두운 색('#1E3A5F')으로 유지한다.
  • startY < midHeight
    • 해가 x축 위에 있을 때 아래쪽 배경을 위쪽 배경과 동일하게 하늘색으로 설정한다.
  • startY >= midHeight
    • 해가 x축 아래에 있을 때 아래쪽 배경을 어두운 색('#1E3A5F')으로 유지한다.
 
배경을 먼저 설정한 후, 그 위에 x축과 sin 그래프를 그려야 한다. 이를 통해 그래프가 배경 위에 제대로 그려지도록 한다. 만약 배경을 X축과 그래프 이후에 설정하면, 배경이 X축과 그래프를 덮어버려 시각적으로 보이지 않게 된다.
  • 순서
      1. 배경을 먼저 설정한다.
      1. X축과 sin 그래프를 배경 위에 그린다.
 
따라서, context.fillRect()를 사용해 캔버스의 위쪽과 아래쪽 배경을 각각 그린다. 위쪽은 항상 하늘색으로 설정되고, 아래쪽 배경은 해의 위치에 따라 동적으로 변한다.
해의 위치에 따른 배경색을 변경하기 위해 startY 값이 midHeight보다 작으면(즉, 해가 x축 위에 있으면) 아래쪽 배경을 하늘색으로, 그렇지 않으면 어두운 색으로 설정한다.
notion imagenotion image
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; // 하늘색 배경 없애기 context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); // 어두운 색 배경 없애기 context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); const phaseShift = (sliderValue * 2 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); // 코드 추가 // 배경을 설정: 해의 y 위치에 따라 아래 배경 색상 변경 context.fillStyle = '#87CEEB'; // 위쪽 배경은 항상 하늘색 context.fillRect(0, 0, canvas.width, midHeight); if (startY < midHeight) { // 해가 x축 위에 있을 때 (낮), 아래 배경도 하늘색으로 변경 context.fillStyle = '#87CEEB'; context.fillRect(0, midHeight, canvas.width, midHeight); } else { // 해가 x축 아래에 있을 때 (밤), 아래 배경은 어두운 색으로 유지 context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); } // 코드 위치 옮기기 // X축 그리기 context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); // 코드 위치 옮기기 // Sin 그래프 context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); context.lineTo(x, y); } context.stroke(); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
 

7.4.7 시간에 따른 위치 계산 및 그래프로 표현하기

  1. sin 그래프 더 빠르게 보기(바의 값에 따른 그래프 확장)
그래프의 위상 이동 속도를 빠르게 하기 위해, 슬라이더 값을 기준으로 그래프의 주기가 더 빠르게 이동하도록 수정하자.
phaseShift 값을 (sliderValue * 8 * Math.PI) / 100로 설정하여, 슬라이더를 움직일 때 그래프가 더 빠르게 이동하도록 한다. 이는 슬라이더가 움직일 때 위상 이동 속도를 빠르게 해 그래프가 즉각적인 변화를 보이도록 한다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; // phaseShift 값을 (sliderValue * 8 * Math.PI) / 100으로 변경 const phaseShift = (sliderValue * 8 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); if (startY < midHeight) { context.fillStyle = '#87CEEB'; context.fillRect(0, midHeight, canvas.width, midHeight); } else { context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); } context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); context.lineTo(x, y); } context.stroke(); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
 
  1. x축과 sin 그래프의 교차점에 선 긋기
sin 그래프가 x축과 교차하는 지점에 세로선을 그리고 교점점을 만들자. sin 그래프의 y값이 x축의 위치인 midHeight와 거의 같은 위치에 있을 때 교차점으로 간주하여 교차점의 x 좌표를 저장하여 교차점을 계산하고 교차점에서 세로선을 그려 sin 그래프가 x축과 만나는 부분을 강조한다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; const phaseShift = (sliderValue * 8 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); if (startY < midHeight) { context.fillStyle = '#87CEEB'; context.fillRect(0, midHeight, canvas.width, midHeight); } else { context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); } context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); let intersectionPoints = []; // 교차 지점 x좌표를 저장할 배열 for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); // y 값이 midHeight에 매우 가까운 경우 교차점으로 간주 if (Math.abs(y - midHeight) < 1) { intersectionPoints.push(x); // 모든 교차점의 x좌표를 저장 } context.lineTo(x, y); } context.stroke(); // 모든 교차점에 세로선 그리기 intersectionPoints.forEach((x) => { context.strokeStyle = '#E3E3E3'; // 회색 context.lineWidth = 1; context.beginPath(); context.moveTo(x, midHeight - 15); // 세로선의 길이를 30px로 그리기 context.lineTo(x, midHeight + 15); // 15px 만큼 아래로 그리기 context.stroke(); }); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
 

7.4.8 디자인 적용하기

  1. 그래프의 색상 분리 및 조건부 색상 적용
x축 위에 있는 그래프는 노란색, x축 아래에 있는 그래프는 회색으로 변경하는 작업을 진행한다. 이를 위해 그래프의 y값에 따라 색상을 다르게 적용한다.
기존에는 beginPath()를 한 번만 호출하여 그래프를 한 번에 그렸지만, 이제 루프 안에서 여러 번 호출하여 선분을 개별적으로 그릴 수 있도록 하자. 이렇게 하면 선분마다 색상을 다르게 설정할 수 있다. context.beginPath() 위치를 변경한다.
if (y < midHeight) 조건을 추가하여, y값이 x축보다 위에 있으면 오렌지색(#FEC62B), 아래에 있으면 회색(#E3E3E3)으로 설정한다. 그리고 각 선분을 그릴 때마다 stroke()를 호출하여 조건에 맞는 색상으로 그리도록 수정하자.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; const phaseShift = (sliderValue * 8 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); if (startY < midHeight) { context.fillStyle = '#87CEEB'; context.fillRect(0, midHeight, canvas.width, midHeight); } else { context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); } context.strokeStyle = '#FFFFFF'; context.lineWidth = 2; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); let intersectionPoints = []; for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); if (Math.abs(y - midHeight) < 1) { intersectionPoints.push(x); } // 그래프의 색상을 x축 위/아래에 따라 다르게 설정 if (y < midHeight) { context.strokeStyle = '#FEC62B'; // 해와 같은 색 (오렌지색) } else { context.strokeStyle = '#E3E3E3'; // 회색 } context.lineTo(x, y); context.stroke(); context.beginPath(); // 각각의 선분을 개별적으로 그리기 위해 경로를 재설정 context.moveTo(x, y); } context.stroke(); intersectionPoints.forEach((x) => { context.strokeStyle = '#E3E3E3'; context.lineWidth = 1; context.beginPath(); context.moveTo(x, midHeight - 15); context.lineTo(x, midHeight + 15); context.stroke(); }); context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
 
  1. 그래프 선 두께, 해의 크기 변경
선 두께를 2px에서 5px로 증가시켜 더 두꺼운 선을 사용한다. lineWidth의 값을 증가시키자. 해를 두 개의 원으로 그린다. 첫 번째 원은 반투명한 오렌지색으로, 두 번째 원은 불투명한 오렌지색으로 하여 크기가 다른 두 원을 중첩 시킨다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; const phaseShift = (sliderValue * 8 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); context.fillStyle = '#87CEEB'; context.fillRect(0, 0, canvas.width, midHeight); if (startY < midHeight) { context.fillStyle = '#87CEEB'; context.fillRect(0, midHeight, canvas.width, midHeight); } else { context.fillStyle = '#1E3A5F'; context.fillRect(0, midHeight, canvas.width, midHeight); } context.strokeStyle = '#FFFFFF'; context.lineWidth = 5; // 그래프 선 두께 2 -> 5로 변경 context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); let intersectionPoints = []; for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); if (Math.abs(y - midHeight) < 1) { intersectionPoints.push(x); } if (y < midHeight) { context.strokeStyle = '#FEC62B'; } else { context.strokeStyle = '#E3E3E3'; } context.lineTo(x, y); context.stroke(); context.beginPath(); context.moveTo(x, y); } context.stroke(); intersectionPoints.forEach((x) => { context.strokeStyle = '#E3E3E3'; context.lineWidth = 1; context.beginPath(); context.moveTo(x, midHeight - 15); context.lineTo(x, midHeight + 15); context.stroke(); }); // 기존의 원(태양) 코드삭제 context.fillStyle = '#FF4500'; context.beginPath(); context.arc(startX, startY, 5, 0, 2 * Math.PI); context.fill(); // 더 큰 원 그리기 (연한 오렌지색) context.fillStyle = 'rgba(254, 198, 43, 0.5)'; // #FEC62B 색상, 오퍼시티 50% context.beginPath(); context.arc(startX, startY, 12, 0, 2 * Math.PI); // 반지름 12px context.fill(); // 해 그리기 (오렌지색) context.fillStyle = '#FEC62B'; // 오렌지색 context.beginPath(); context.arc(startX, startY, 8, 0, 2 * Math.PI); // 반지름 8px (기존보다 크게) context.fill(); }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
 
  1. 배경 및 x축 색상 변경
배경과 x축의 색상을 변경하자. 위쪽 배경은 흰색(#FFFFFF), 아래쪽 배경은 밤을 나타내는 진한 회색(#171717)로 설정한다. x축의 색상은 회색('#E3E3E3')으로 설정하고, 선의 두께를 5px로 증가시킨다. y축 아래 그래프의 색상은 #E3E3E3에서 #888888로 변경한다.
notion imagenotion image
import { useRef, useEffect, useState } from 'react'; const SunriseSunset = () => { const canvasRef = useRef(null); const [canvasWidth, setCanvasWidth] = useState(window.innerWidth); const [sliderValue, setSliderValue] = useState(0); useEffect(() => { const canvas = canvasRef.current; const context = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = 400; const midHeight = canvas.height / 2; const phaseShift = (sliderValue * 8 * Math.PI) / 100; const startX = canvas.width * 0.3; const startY = midHeight - 100 * Math.sin((startX / canvas.width) * 2 * Math.PI + phaseShift); context.fillStyle = '#FFFFFF'; // 87CEEB -> FFFFFF로 변경 context.fillRect(0, 0, canvas.width, midHeight); if (startY < midHeight) { context.fillStyle = '#FFFFFF'; // 87CEEB -> FFFFFF로 변경 context.fillRect(0, midHeight, canvas.width, midHeight); } else { context.fillStyle = '#171717'; // 1E3A5F -> 171717로 변경 context.fillRect(0, midHeight, canvas.width, midHeight); } context.strokeStyle = '#FFFFFF'; // FFFFFF -> E3E3E3로 변경 context.lineWidth = 5; context.beginPath(); context.moveTo(0, midHeight); context.lineTo(canvas.width, midHeight); context.stroke(); context.strokeStyle = '#FFD700'; context.lineWidth = 3; context.beginPath(); let intersectionPoints = []; for (let x = 0; x < canvas.width; x++) { const y = midHeight - 100 * Math.sin((x / canvas.width) * 2 * Math.PI + phaseShift); if (Math.abs(y - midHeight) < 1) { intersectionPoints.push(x); } if (y < midHeight) { context.strokeStyle = '#FEC62B'; } else { context.strokeStyle = '#888888'; // E3E3E3 -> 888888로 변경 } context.lineTo(x, y); context.stroke(); context.beginPath(); context.moveTo(x, y); } context.stroke(); intersectionPoints.forEach((x) => { context.strokeStyle = '#888888'; // E3E3E3 -> 888888로 변경 context.lineWidth = 1; context.beginPath(); context.moveTo(x, midHeight - 15); context.lineTo(x, midHeight + 15); context.stroke(); }); context.fillStyle = 'rgba(254, 198, 43, 0.5)'; context.beginPath(); context.arc(startX, startY, 12, 0, 2 * Math.PI); context.fill(); context.fillStyle = '#FEC62B'; context.beginPath(); context.arc(startX, startY, 8, 0, 2 * Math.PI); context.fill(); }, [canvasWidth, sliderValue]); return ( <div> <canvas ref={canvasRef} /> <input type="range" style={{ width: `${canvasWidth}px`, marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} /> </div> ); }; export default SunriseSunset;
💡
깃허브에는 다음의 코드로 작성되어 있다.
// window.innerWidth -> 800으로 변경 canvas.width = 800; ... {/* 슬라이더 가로 길이 `${canvasWidth}px` -> 800px로 변경 */} <input type="range" style={{ width: '800px', marginTop: '20px' }} min="0" max="100" value={sliderValue} onChange={(e) => setSliderValue(e.target.value)} />