01Line chart draw
Press the button and twelve weeks of sales draw themselves through quadraticCurveTo with softened corners while a clipping rect sweeps open left to right, revealing the line in order.
Best forThe first row of a weekly or monthly report
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>A 12-week sales line draws itself from left to right — <code>quadraticCurveTo</code>·<code>clip</code>·<code>lineTo</code></span>
</p>
<div class="lc__field" id="field" role="application" aria-label="Line chart drawing test board" tabindex="0">
<canvas class="lc__cv" id="cv" role="img" aria-label="12-week sales line chart — a bending line climbing week by week"></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>Redraw</span>
</button>
<table class="sr-only">
<caption>12-week sales (unit: units)</caption>
<thead><tr><th scope="col">Week</th><th scope="col">Sales</th></tr></thead>
<tbody>
<tr><th scope="row">W1</th><td>128</td></tr>
<tr><th scope="row">W2</th><td>176</td></tr>
<tr><th scope="row">W3</th><td>142</td></tr>
<tr><th scope="row">W4</th><td>210</td></tr>
<tr><th scope="row">W5</th><td>248</td></tr>
<tr><th scope="row">W6</th><td>204</td></tr>
<tr><th scope="row">W7</th><td>292</td></tr>
<tr><th scope="row">W8</th><td>336</td></tr>
<tr><th scope="row">W9</th><td>288</td></tr>
<tr><th scope="row">W10</th><td>372</td></tr>
<tr><th scope="row">W11</th><td>414</td></tr>
<tr><th scope="row">W12</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;
}
}// 선 그래프 그리기 — W12 판매량을 이어 그리는 애니메이션.
// 두 겹이 함께 움직인다:
// 1) 끝점이 x 축을 따라 이동하며, 지나온 꼭짓점들은 quadraticCurveTo 로 모서리를 눌러 이어진다(중점 제어).
// 2) 클리핑 사각형이 같은 속도로 왼쪽에서 오른쪽으로 열려, 선이 지나온 순서대로 드러난다.
// 그리드와 Axis 라벨은 정적이므로 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';
// 고정 시드 데이터 — W12 Sales(개). 난수가 아니라 코드 안 배열이므로 다시 그려도 같은 그림
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; // Axis 라벨 여백(CSS px)
var LOOP = 2; // 한 사이클 2초 — 캡처 창과 같아 gif 가 이어진다
var DRAW_AT = 0.88; // 전체 2초의 88% 에 그리기가 끝나고, 끝점이 커지며 잠시 쉰다
var Y_TOP = 100, Y_SPAN = 500; // y Axis 스케일 고정(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 라벨(Week) — 축이 뜬 뒤 왼쪽부터 차례로 나타난다
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;
}
// 끝 Value 라벨 — 끝점 위에서 따라오르는 Value(변 위에서 보간되어 카운트업처럼 오른다)
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 — 루프 없이 완성 상태(W12 전체가 그려지고 끝점이 커진 한 장)만 그린다
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 Line chart draw — Measured area 20.228% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · JavaScript Chart Drawing, 9 Ways — No Library
The post this demo lives in
JavaScript Chart Drawing, 9 Ways — No LibraryA javascript chart is a data array turned into x·y coordinates painted onto a canvas as lines, bars, and arcs. These nin See all demos in this post
If you like this effect - similar demos
Dashboard UI01 Line chart that draws itselfCSS Chart Design: 9 SVG Graphs, No Library
Dashboard UI07 Drag the goal line and only the bars above change colorChart UI Design: 9 Interactions You Can Drag
Dashboard UI01 A Pie Chart That Sweeps In Clockwise, One Slice After AnotherPie Chart CSS: 9 Slices That Pop, Tilt and Morph