01선 그래프 그리기
버튼을 누르면 12주 판매량이 quadraticCurveTo 로 모서리를 눌러 그려지고 끝점이 커지며 멈춘다. 클리핑 사각형이 왼쪽에서 오른쪽으로 열리므로 선은 지나온 순서대로 드러난다.
쓰임주간·월간 지표 보고서 첫 줄
quadraticCurveTocliplineTo

<main class="stage">
<div class="lc" id="lc">
<p class="lc__hint">
<svg class="lc__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 64v144H32V48h176a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 208a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V48a8 8 0 0 1 16 0v108.69l50.34-50.35a8 8 0 0 1 11.32 0L128 132.69L180.69 80H160a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8v40a8 8 0 0 1-16 0V91.31l-58.34 58.35a8 8 0 0 1-11.32 0L96 123.31l-56 56V200h184a8 8 0 0 1 8 8"/></g></svg>
<span>12주 판매량 선이 왼쪽에서 오른쪽으로 그려집니다 — <code>quadraticCurveTo</code>·<code>clip</code>·<code>lineTo</code></span>
</p>
<div class="lc__field" id="field" role="application" aria-label="선 그래프 그리기 실험판" tabindex="0">
<canvas class="lc__cv" id="cv" role="img" aria-label="12주 판매량 선 그래프 — 꺾이는 선이 주차를 따라 오르는 모습"></canvas>
</div>
<div class="lc__bar">
<button class="lc__btn" id="replay" type="button">
<svg class="lc__bico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
<span>다시 그리기</span>
</button>
<table class="sr-only">
<caption>12주 판매량(단위: 개)</caption>
<thead><tr><th scope="col">주차</th><th scope="col">판매량</th></tr></thead>
<tbody>
<tr><th scope="row">1주</th><td>128</td></tr>
<tr><th scope="row">2주</th><td>176</td></tr>
<tr><th scope="row">3주</th><td>142</td></tr>
<tr><th scope="row">4주</th><td>210</td></tr>
<tr><th scope="row">5주</th><td>248</td></tr>
<tr><th scope="row">6주</th><td>204</td></tr>
<tr><th scope="row">7주</th><td>292</td></tr>
<tr><th scope="row">8주</th><td>336</td></tr>
<tr><th scope="row">9주</th><td>288</td></tr>
<tr><th scope="row">10주</th><td>372</td></tr>
<tr><th scope="row">11주</th><td>414</td></tr>
<tr><th scope="row">12주</th><td>468</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 선 그래프 그리기 — 움직임은 전부 캔버스 rAF 루프 안(끝점 이동 + quadraticCurveTo 경로 + clip 리빌).
// CSS 애니메이션이 0개여야 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 무대는 $stage-paper(흰) — 차트는 밝은 무대가 정석. 색은 차트 토큰 고정 순서 1번.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $chart-1;
@include stage($stage-paper);
// 화면 낭독기용 표 — 캔버스를 읽지 못하므로 같은 데이터를 표로 붙인다
.sr-only {
// fixed + top/left 명시 — absolute 였을 때 표의 실제 콘텐츠 높이(12행 ≒ 390px)가 scrollHeight 에
// 잡혀 데스크톱·폰 전부 세로 넘침(BD8/BD9)이 났다. top 을 주지 않으면 static 자리 기준으로
// 배치돼 여전히 scrollHeight 에 들어간다 — 좌상단 고정으로 문서 흐름에서 완전히 뺀다.
position: fixed; top: 0; left: 0; width: 1px; height: 1px; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.lc {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다. vivid 리터럴은 두지 않는다(BD26)
--chart-1: #{$color};
--fill-1: #{rgba($chart-1, .12)};
--ink: #{$subject-ink};
--mute: #{rgba($subject-ink, .62)};
--grid: #{rgba($subject-ink, .10)};
--paper: #{$stage-paper};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lc__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.lc__hint code {
padding: 0 $sp-1; border-radius: $r-xs;
background: rgba($chart-1, .10);
color: $subject-ink;
font-weight: 700; font-size: .92em; line-height: 1.3;
}
.lc__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.lc__field {
position: relative; height: 176px;
border-radius: $r-card; overflow: hidden;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.lc__cv { display: block; width: 100%; height: 100%; }
.lc__bar {
display: flex; align-items: center; gap: $sp-2;
}
.lc__btn {
display: inline-flex; align-items: center; gap: $sp-1;
box-sizing: border-box; padding: $sp-2 $sp-4; border: 0;
border-radius: $r-pill; background: $subject-blue-deep; color: $subject-cream;
font-family: inherit; font-weight: 700; font-size: 12px; line-height: 1.2;
cursor: pointer;
}
.lc__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lc__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.lc { gap: $sp-1; }
.lc__field { height: 112px; }
.lc__hint { font-size: 10px; }
.lc__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 완성 상태 한 장이 남으므로 "다시 그리기"는 여전히 정지 이미지를 다시 그린다.
@media (prefers-reduced-motion: reduce) {
.lc__btn { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr-only {
position: fixed;
top: 0;
left: 0;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.lc {
--chart-1: #2f6df6;
--fill-1: rgba(47, 109, 246, 0.12);
--ink: #17141a;
--mute: rgba(23, 20, 26, 0.62);
--grid: rgba(23, 20, 26, 0.1);
--paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lc__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.lc__hint code {
padding: 0 4px;
border-radius: 4px;
background: rgba(47, 109, 246, 0.1);
color: #17141a;
font-weight: 700;
font-size: 0.92em;
line-height: 1.3;
}
.lc__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.lc__field {
position: relative;
height: 176px;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.lc__cv {
display: block;
width: 100%;
height: 100%;
}
.lc__bar {
display: flex;
align-items: center;
gap: 8px;
}
.lc__btn {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
padding: 8px 16px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.2;
cursor: pointer;
}
.lc__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lc__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.lc {
gap: 4px;
}
.lc__field {
height: 112px;
}
.lc__hint {
font-size: 10px;
}
.lc__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.lc__btn {
cursor: default;
}
}// 선 그래프 그리기 — 12주 판매량을 이어 그리는 애니메이션.
// 두 겹이 함께 움직인다:
// 1) 끝점이 x 축을 따라 이동하며, 지나온 꼭짓점들은 quadraticCurveTo 로 모서리를 눌러 이어진다(중점 제어).
// 2) 클리핑 사각형이 같은 속도로 왼쪽에서 오른쪽으로 열려, 선이 지나온 순서대로 드러난다.
// 그리드와 축 라벨은 정적이므로 reveal 초반에 먼저 띄우고, 선이 그 위를 지나간다.
var root = document.getElementById('lc');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var replayBtn = document.getElementById('replay');
var css = getComputedStyle(root);
var C_LINE = css.getPropertyValue('--chart-1').trim();
var C_FILL = css.getPropertyValue('--fill-1').trim();
var C_INK = css.getPropertyValue('--ink').trim();
var C_MUTE = css.getPropertyValue('--mute').trim();
var C_GRID = css.getPropertyValue('--grid').trim();
var C_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 고정 시드 데이터 — 12주 판매량(개). 난수가 아니라 코드 안 배열이므로 다시 그려도 같은 그림
var WEEKS = [128, 176, 142, 210, 248, 204, 292, 336, 288, 372, 414, 468];
var PAD_L = 40, PAD_R = 16, PAD_T = 18, PAD_B = 30; // 축 라벨 여백(CSS px)
var LOOP = 2; // 한 사이클 2초 — 캡처 창과 같아 gif 가 이어진다
var DRAW_AT = 0.88; // 전체 2초의 88% 에 그리기가 끝나고, 끝점이 커지며 잠시 쉰다
var Y_TOP = 100, Y_SPAN = 500; // y 축 스케일 고정(100~500개)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var t0 = 0, raf = 0;
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
// 데이터 인덱스 → 플롯 좌표. y 는 위로 갈수록 크다
function xAt(i) { return PAD_L + (W - PAD_L - PAD_R) * (i / (WEEKS.length - 1)); }
function yAt(v) { return PAD_T + (H - PAD_T - PAD_B) * (1 - (v - Y_TOP) / (Y_SPAN - Y_TOP)); }
// 그리기 진행 p(0..1) → { i, k, ex, ey, f }
// f = 0..11 정규화 경과, i·k = 현재 변, (ex,ey) = 끝점 좌표
function headAt(p) {
var n = WEEKS.length;
var f = Math.min(1, p / DRAW_AT) * (n - 1);
var i = Math.min(n - 2, Math.floor(f));
var k = f - i;
var ex = xAt(i) + (xAt(i + 1) - xAt(i)) * k;
var ey = yAt(WEEKS[i]) + (yAt(WEEKS[i + 1]) - yAt(WEEKS[i])) * k;
return { i: i, k: k, f: f, ex: ex, ey: ey };
}
// 현재 끝점까지의 부드러운 경로를 만든다.
// 지나온 꼭짓점은 제어점으로 삼아 다음 꼭짓점과의 중점까지 quadraticCurveTo 로 누른다(정점을 스치듯),
// 마지막 구간은 현재 끝점까지 직선(lineTo) — 그리고 있는 순간의 펜 끝이다.
function tracePath(p) {
var d = headAt(p);
ctx.beginPath();
ctx.moveTo(xAt(0), yAt(WEEKS[0]));
for (var j = 1; j < d.i; j++) {
ctx.quadraticCurveTo(xAt(j), yAt(WEEKS[j]), (xAt(j) + xAt(j + 1)) / 2, (yAt(WEEKS[j]) + yAt(WEEKS[j + 1])) / 2);
}
if (d.i >= 1) {
ctx.quadraticCurveTo(xAt(d.i), yAt(WEEKS[d.i]), d.ex, d.ey);
} else {
ctx.lineTo(d.ex, d.ey);
}
return d;
}
// 목표를 살짝 넘겼다가 돌아오는 오버슛 — 끝점이 자리 잡을 때 커지는 감
function easeOutBack(t) {
var c = 1.7;
return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2);
}
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 가로 그리드 + y 라벨 — 100~500 다섯 줄. 첫 12% 동안 아래에서 위로 떠오른다
var axisP = Math.min(1, p / 0.12);
ctx.lineWidth = 1;
ctx.font = '600 10px ' + FONT;
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (var g = Y_TOP; g <= Y_SPAN; g += 100) {
var gy = yAt(g) + (1 - axisP) * 14;
ctx.strokeStyle = C_GRID;
ctx.beginPath();
ctx.moveTo(PAD_L, Math.round(gy) + 0.5);
ctx.lineTo(W - PAD_R, Math.round(gy) + 0.5);
ctx.stroke();
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(String(g), PAD_L - 6, gy);
ctx.globalAlpha = 1;
}
// x 라벨(주차) — 축이 뜬 뒤 왼쪽부터 차례로 나타난다
ctx.textAlign = 'center';
for (var w = 0; w < WEEKS.length; w++) {
var wp = Math.max(0, Math.min(1, (p - 0.10 - w * 0.018) / 0.10));
if (wp <= 0) continue;
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = wp;
ctx.fillText('W' + (w + 1), xAt(w), H - PAD_B + 13);
ctx.globalAlpha = 1;
}
// 클리핑 사각형 — 왼쪽에서 오른쪽으로 열린다. 이 안에서만 면과 선이 보인다
var cutX = PAD_L + (W - PAD_L - PAD_R) * Math.min(1, p / DRAW_AT);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, cutX + 1, H);
ctx.clip();
// 영역 채움(면) — 선 아래를 옅게 깐다. 움직이는 면적이 커야 측정 강도가 오른다
var d0 = tracePath(p);
ctx.lineTo(d0.ex, H - PAD_B);
ctx.lineTo(xAt(0), H - PAD_B);
ctx.closePath();
ctx.fillStyle = C_FILL;
ctx.fill();
// 선 — 3px. 경로를 다시 만들어 그린다
var d1 = tracePath(p);
ctx.strokeStyle = C_LINE;
ctx.lineWidth = 3;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.stroke();
ctx.restore();
// 꼭짓점 마커 — 끝점이 지나온 점만 종이색 속에 파란 테두리로 박는다(지나온 순서 확인)
for (var m = 0; m <= d1.i; m++) {
ctx.beginPath();
ctx.arc(xAt(m), yAt(WEEKS[m]), 3.2, 0, Math.PI * 2);
ctx.fillStyle = C_PAPER;
ctx.fill();
ctx.strokeStyle = C_LINE;
ctx.lineWidth = 2;
ctx.stroke();
}
// 끝점 — 그리기가 끝나면 오버슛으로 커진다(2.6 에서 5.5 로). 커지며 링이 한 번 퍼진다
var settled = Math.max(0, (p - DRAW_AT) / (1 - DRAW_AT));
var r0 = 2.6 + 2.9 * easeOutBack(Math.min(1, settled * 1.8));
ctx.beginPath();
ctx.arc(d1.ex, d1.ey, r0, 0, Math.PI * 2);
ctx.fillStyle = C_LINE;
ctx.fill();
if (settled > 0 && settled < 1) {
ctx.beginPath();
ctx.arc(d1.ex, d1.ey, r0 + 14 * settled, 0, Math.PI * 2);
ctx.strokeStyle = C_LINE;
ctx.globalAlpha = 0.5 * (1 - settled);
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 1;
}
// 끝 값 라벨 — 끝점 위에서 따라오르는 값(변 위에서 보간되어 카운트업처럼 오른다)
if (p > 0.06) {
var lv = Math.round(WEEKS[d1.i] + (WEEKS[d1.i + 1] - WEEKS[d1.i]) * d1.k);
ctx.font = '700 12px ' + FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = C_INK;
ctx.fillText(String(lv), Math.min(W - 20, Math.max(PAD_L, d1.ex)), d1.ey - 12 - 6 * settled);
}
}
function frame(now) {
if (!t0) t0 = now;
var p = ((now - t0) / 1000 % LOOP) / LOOP; // 0..1, 2초 루프
draw(p);
raf = requestAnimationFrame(frame);
}
replayBtn.addEventListener('click', function () { t0 = 0; });
window.addEventListener('resize', function () { fit(); if (reduce) draw(1); });
fit();
if (reduce) {
// reduce — 루프 없이 완성 상태(12주 전체가 그려지고 끝점이 커진 한 장)만 그린다
draw(1);
} else {
raf = requestAnimationFrame(frame);
}// LineDrawChart — vanilla 판(run/305/demo/01-chart-canvas-line-draw/index.html) 의 React 포트.
// 끝점이 x 축을 따라 이동하며 지나온 꼭짓점은 quadraticCurveTo 로 눌리고,
// 클리핑 사각형이 왼쪽에서 오른쪽으로 열려 선이 지나온 순서대로 드러난다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LineDrawChart.module.scss";
export type LineDrawChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 고정 시드 데이터 — 12주 판매량(개). 난수가 아니라 코드 안 배열
const WEEKS = [128, 176, 142, 210, 248, 204, 292, 336, 288, 372, 414, 468];
const PAD_L = 40, PAD_R = 16, PAD_T = 18, PAD_B = 30;
const LOOP = 2; // 한 사이클 2초
const DRAW_AT = 0.88; // 전체의 88% 에 그리기가 끝나고 끝점이 커지며 쉰다
const Y_TOP = 100, Y_SPAN = 500;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "선 그래프 그리기 실험판";
const CV_LABEL = "12주 판매량 선 그래프 — 꺾이는 선이 주차를 따라 오르는 모습";
export default function LineDrawChart({
duration = "2s", // vanilla $duration: $dur-loop
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#2f6df6", // vanilla $color: $chart-1
}: LineDrawChartProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const t0Ref = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function cssv(n: string): string {
const root = rootRef.current;
return root ? getComputedStyle(root).getPropertyValue(n).trim() : "";
}
// 고DPI — 배킹 스토어는 devicePixelRatio 배, 그리기 좌표계는 CSS 픽셀 그대로
function fit(): void {
const cv = cvRef.current;
const s = sizeRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
s.w = r.width; s.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function xAt(i: number): number {
const W = sizeRef.current.w;
return PAD_L + (W - PAD_L - PAD_R) * (i / (WEEKS.length - 1));
}
function yAt(v: number): number {
const H = sizeRef.current.h;
return PAD_T + (H - PAD_T - PAD_B) * (1 - (v - Y_TOP) / (Y_SPAN - Y_TOP));
}
// 그리기 진행 p(0..1) → 끝점 정보. f = 정규화 경과, i·k = 현재 변, (ex,ey) = 끝점
function headAt(p: number): { i: number; k: number; f: number; ex: number; ey: number } {
const n = WEEKS.length;
const f = Math.min(1, p / DRAW_AT) * (n - 1);
const i = Math.min(n - 2, Math.floor(f));
const k = f - i;
const ex = xAt(i) + (xAt(i + 1) - xAt(i)) * k;
const ey = yAt(WEEKS[i]) + (yAt(WEEKS[i + 1]) - yAt(WEEKS[i])) * k;
return { i, k, f, ex, ey };
}
// 지나온 꼭짓점을 중점 제어로 누른 경로. 마지막 구간은 현재 끝점까지 직선
function tracePath(p: number): { i: number; k: number; f: number; ex: number; ey: number } {
const ctx = ctxRef.current!;
const d = headAt(p);
ctx.beginPath();
ctx.moveTo(xAt(0), yAt(WEEKS[0]));
for (let j = 1; j < d.i; j++) {
ctx.quadraticCurveTo(xAt(j), yAt(WEEKS[j]), (xAt(j) + xAt(j + 1)) / 2, (yAt(WEEKS[j]) + yAt(WEEKS[j + 1])) / 2);
}
if (d.i >= 1) {
ctx.quadraticCurveTo(xAt(d.i), yAt(WEEKS[d.i]), d.ex, d.ey);
} else {
ctx.lineTo(d.ex, d.ey);
}
return d;
}
// 목표를 살짝 넘겼다가 돌아오는 오버슛 — 끝점이 자리 잡을 때 커지는 감
function easeOutBack(t: number): number {
const c = 1.7;
return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2);
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const C_LINE = cssv("--chart-1") || color;
const C_FILL = cssv("--fill-1");
const C_INK = cssv("--ink");
const C_MUTE = cssv("--mute");
const C_GRID = cssv("--grid");
const C_PAPER = cssv("--paper");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 가로 그리드 + y 라벨 — 첫 12% 동안 아래에서 위로 떠오른다
const axisP = Math.min(1, p / 0.12);
ctx.lineWidth = 1;
ctx.font = "600 10px " + FONT;
ctx.textBaseline = "middle";
ctx.textAlign = "right";
for (let g = Y_TOP; g <= Y_SPAN; g += 100) {
const gy = yAt(g) + (1 - axisP) * 14;
ctx.strokeStyle = C_GRID;
ctx.beginPath();
ctx.moveTo(PAD_L, Math.round(gy) + 0.5);
ctx.lineTo(W - PAD_R, Math.round(gy) + 0.5);
ctx.stroke();
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(String(g), PAD_L - 6, gy);
ctx.globalAlpha = 1;
}
// x 라벨(주차) — 왼쪽부터 차례로
ctx.textAlign = "center";
for (let w = 0; w < WEEKS.length; w++) {
const wp = Math.max(0, Math.min(1, (p - 0.10 - w * 0.018) / 0.10));
if (wp <= 0) continue;
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = wp;
ctx.fillText("W" + (w + 1), xAt(w), H - PAD_B + 13);
ctx.globalAlpha = 1;
}
// 클리핑 사각형 — 왼쪽에서 오른쪽으로 열린다
const cutX = PAD_L + (W - PAD_L - PAD_R) * Math.min(1, p / DRAW_AT);
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, cutX + 1, H);
ctx.clip();
// 영역 채움 — 선 아래를 옅게 깐다
const d0 = tracePath(p);
ctx.lineTo(d0.ex, H - PAD_B);
ctx.lineTo(xAt(0), H - PAD_B);
ctx.closePath();
ctx.fillStyle = C_FILL;
ctx.fill();
// 선 — 2px
const d1 = tracePath(p);
ctx.strokeStyle = C_LINE;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.stroke();
ctx.restore();
// 꼭짓점 마커 — 끝점이 지나온 점만
for (let m = 0; m <= d1.i; m++) {
ctx.beginPath();
ctx.arc(xAt(m), yAt(WEEKS[m]), 2.6, 0, Math.PI * 2);
ctx.fillStyle = C_PAPER;
ctx.fill();
ctx.strokeStyle = C_LINE;
ctx.lineWidth = 2;
ctx.stroke();
}
// 끝점 — 착지하며 오버슛으로 커지고 링이 한 번 퍼진다
const settled = Math.max(0, (p - DRAW_AT) / (1 - DRAW_AT));
const r0 = 2.6 + 2.9 * easeOutBack(Math.min(1, settled * 1.8));
ctx.beginPath();
ctx.arc(d1.ex, d1.ey, r0, 0, Math.PI * 2);
ctx.fillStyle = C_LINE;
ctx.fill();
if (settled > 0 && settled < 1) {
ctx.beginPath();
ctx.arc(d1.ex, d1.ey, r0 + 14 * settled, 0, Math.PI * 2);
ctx.strokeStyle = C_LINE;
ctx.globalAlpha = 0.5 * (1 - settled);
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 1;
}
// 끝 값 라벨 — 끝점 위에서 보간되어 오른다
if (p > 0.06) {
const lv = Math.round(WEEKS[d1.i] + (WEEKS[d1.i + 1] - WEEKS[d1.i]) * d1.k);
ctx.font = "700 12px " + FONT;
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = C_INK;
ctx.fillText(String(lv), Math.min(W - 20, Math.max(PAD_L, d1.ex)), d1.ey - 12 - 6 * settled);
}
}
function frame(now: number): void {
if (!t0Ref.current) t0Ref.current = now;
const p = ((now - t0Ref.current) / 1000 % LOOP) / LOOP;
draw(p);
rafRef.current = requestAnimationFrame(frame);
}
function replay(): void {
t0Ref.current = 0;
}
function onResize(): void {
fit();
if (reduced()) draw(1);
}
useEffect(() => {
const cv = cvRef.current;
if (!cv) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 루프 없이 완성 상태 한 장만 그린다
draw(1);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 64v144H32V48h176a16 16 0 0 1 16 16" opacity=".2"/><path d="M232 208a8 8 0 0 1-8 8H32a8 8 0 0 1-8-8V48a8 8 0 0 1 16 0v108.69l50.34-50.35a8 8 0 0 1 11.32 0L128 132.69L180.69 80H160a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8v40a8 8 0 0 1-16 0V91.31l-58.34 58.35a8 8 0 0 1-11.32 0L96 123.31l-56 56V200h184a8 8 0 0 1 8 8"/></g></svg>
<span>12주 판매량 선이 왼쪽에서 오른쪽으로 그려집니다 — <code>quadraticCurveTo</code>·<code>clip</code>·<code>lineTo</code></span>
</p>
<div className={styles.field} role="application" aria-label={FIELD_LABEL} tabIndex={0}>
<canvas className={styles.cv} ref={cvRef} role="img" aria-label={CV_LABEL}></canvas>
</div>
<div className={styles.bar}>
<button className={styles.btn} type="button" onClick={replay}>
<svg className={styles.bico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 128a88 88 0 1 1-88-88a88 88 0 0 1 88 88" opacity=".2"/><path d="M224 128a96 96 0 0 1-94.71 96H128a95.38 95.38 0 0 1-65.9-26.2a8 8 0 0 1 11-11.63a80 80 0 1 0-1.67-114.78a3 3 0 0 1-.26.25L44.59 96H72a8 8 0 0 1 0 16H24a8 8 0 0 1-8-8V56a8 8 0 0 1 16 0v29.8L60.25 60A96 96 0 0 1 224 128"/></g></svg>
<span>다시 그리기</span>
</button>
<table className={styles.srOnly}>
<caption>12주 판매량(단위: 개)</caption>
<thead><tr><th scope="col">주차</th><th scope="col">판매량</th></tr></thead>
<tbody>
{WEEKS.map((v, i) => (
<tr key={i}><th scope="row">{i + 1}주</th><td>{v}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}01 선 그래프 그리기 — 측정 누적면적 20.228% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종
이 데모가 속한 글
canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종canvas 그래프 그리기는 데이터 배열을 x·y 좌표로 바꿔 캔버스 위에 선·막대·원호로 얹는 일입니다. 라이브러리와 CDN 없이 값이 그려지고 차오르고 모여드는 차트 아홉 종을 만들었고, 파일만 열면 복사한 그대 이 글의 데모 전부 보기


