02막대 그래프 성장
다섯 채널의 매출이 바닥에서 값을 향해 초격차로 자라되 시작이 시간차(stagger)로 걸려 파도처럼 번진다. 막대가 다 자란 뒤 값 레이블이 카운트업된다.
쓰임채널·지점 비교 대시보드
staggereaseOutCubicfillRect

<main class="stage">
<div class="bg" id="bg">
<p class="bg__hint">
<svg class="bg__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg>
<span>다섯 채널 막대가 시간차로 자라 파도가 됩니다 — <code>stagger</code>·<code>easeOutCubic</code>·<code>fillRect</code></span>
</p>
<div class="bg__field" id="field" role="application" aria-label="막대 그래프 성장 실험판" tabindex="0">
<canvas class="bg__cv" id="cv" role="img" aria-label="다섯 채널 매출 막대 그래프 — 막대가 바닥에서 값까지 자라는 모습"></canvas>
</div>
<div class="bg__bar">
<button class="bg__btn" id="replay" type="button">
<svg class="bg__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>채널별 분기 매출(단위: 만원)</caption>
<thead><tr><th scope="col">채널</th><th scope="col">매출</th></tr></thead>
<tbody>
<tr><th scope="row">검색</th><td>420</td></tr>
<tr><th scope="row">소셜</th><td>310</td></tr>
<tr><th scope="row">이메일</th><td>265</td></tr>
<tr><th scope="row">직접</th><td>195</td></tr>
<tr><th scope="row">파트너</th><td>140</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 막대 그래프 성장 — 움직임은 전부 캔버스 rAF 루프 안(막대 stagger 성장 + 값 카운트업).
// CSS 애니메이션 0개 — 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 막대 여러 개는 차트 토큰 고정 순서대로. 2·3·4번 계열은 파스텔 카운터파트(한 단 밝게)로 밝은 무대에 맞춘다.
$duration: $dur-loop;
$easing: $ease-out; // easeOutCubic 을 JS 에서 계산하므로 CSS 쪽 이징 토큰은 감속 역할명으로
$color: $chart-1;
@include stage($stage-paper);
// 화면 낭독기용 표 — 캔버스를 읽지 못하므로 같은 데이터를 표로 붙인다
.sr-only {
// fixed + top/left 명시 — absolute 였을 때 표의 실제 콘텐츠 높이(행 수만큼 ≒ 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;
}
.bg {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다
--chart-1: #{$chart-1};
--chart-2: #{$chart-2};
--chart-3: #{$chart-3};
--chart-4: #{$chart-4};
// 파스텔 카운터파트 — 어두운 무대 기준 계열색을 흰 무대 위 가시에 맞춘 리프트
--chart-2-lift: #{color.mix(#ffffff, $chart-2, 18%)};
--chart-3-lift: #{color.mix(#ffffff, $chart-3, 14%)};
--chart-4-lift: #{color.mix(#ffffff, $chart-4, 16%)};
--ink: #{$subject-ink};
--mute: #{rgba($subject-ink, .62)};
--grid: #{rgba($subject-ink, .10)};
--track: #{rgba($subject-ink, .06)};
--paper: #{$stage-paper};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.bg__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;
}
.bg__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;
}
.bg__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.bg__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);
}
.bg__cv { display: block; width: 100%; height: 100%; }
.bg__bar {
display: flex; align-items: center; gap: $sp-2;
}
.bg__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;
}
.bg__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.bg__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.bg { gap: $sp-1; }
.bg__field { height: 112px; }
.bg__hint { font-size: 10px; }
.bg__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끌 애니메이션이 없다. 완성 상태 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.bg__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;
}
.bg {
--chart-1: #2f6df6;
--chart-2: #c2361a;
--chart-3: #2b9d74;
--chart-4: #8a5cd6;
--chart-2-lift: rgb(80.3843137255%, 35.3647058824%, 26.3607843137%);
--chart-3-lift: rgb(28.5019607843%, 66.9490196078%, 53.1215686275%);
--chart-4-lift: rgb(61.4588235294%, 46.3058823529%, 86.4941176471%);
--ink: #17141a;
--mute: rgba(23, 20, 26, 0.62);
--grid: rgba(23, 20, 26, 0.1);
--track: rgba(23, 20, 26, 0.06);
--paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.bg__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;
}
.bg__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;
}
.bg__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.bg__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);
}
.bg__cv {
display: block;
width: 100%;
height: 100%;
}
.bg__bar {
display: flex;
align-items: center;
gap: 8px;
}
.bg__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;
}
.bg__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.bg__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.bg {
gap: 4px;
}
.bg__field {
height: 112px;
}
.bg__hint {
font-size: 10px;
}
.bg__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.bg__btn {
cursor: default;
}
}// 막대 그래프 성장 — 다섯 채널 매출이 바닥에서 값까지 자란다.
// 각 막대의 시작 시각이 오른쪽으로 갈수록 늦춰져(stagger) 파도처럼 번지고,
// 성장 곡선은 easeOutCubic(처음 빠르고 끝에서 뭉툭해지는 감속) — 막대가 "콩" 하고 착지하는 맛을 만든다.
// 막대가 다 자란 뒤(각자 자기 시각에) 값 레이블이 0 에서 값까지 카운트업된다.
var root = document.getElementById('bg');
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_1 = css.getPropertyValue('--chart-1').trim();
var C_2 = css.getPropertyValue('--chart-2').trim();
var C_3 = css.getPropertyValue('--chart-3').trim();
var C_4 = css.getPropertyValue('--chart-4').trim();
var C_2L = css.getPropertyValue('--chart-2-lift').trim();
var C_3L = css.getPropertyValue('--chart-3-lift').trim();
var C_4L = css.getPropertyValue('--chart-4-lift').trim();
var C_INK = css.getPropertyValue('--ink').trim();
var C_MUTE = css.getPropertyValue('--mute').trim();
var C_GRID = css.getPropertyValue('--grid').trim();
var C_TRACK = css.getPropertyValue('--track').trim();
var C_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 고정 시드 데이터 — 다섯 채널 분기 매출(만원). 파스텔 카운터파트: 첫 계열은 그대로, 나머지는 한 단계 밝게
var ROWS = [
{ name: '검색', v: 420, c: C_1, lift: C_1 },
{ name: '소셜', v: 310, c: C_2, lift: C_2L },
{ name: '이메일', v: 265, c: C_3, lift: C_3L },
{ name: '직접', v: 195, c: C_4, lift: C_4L },
{ name: '파트너', v: 140, c: C_2, lift: C_2L }
];
var PAD_L = 52, PAD_R = 14, PAD_T = 20, PAD_B = 26;
var LOOP = 2; // 한 사이클 2초
var GROW = 0.46; // 막대 하나가 자라는 데 쓰는 구간(전체의 46%)
var STAGGER = 0.075; // 오른쪽 막대가 늦게 시작하는 간격 — 5개면 마지막은 0.30 에 시작
var COUNT_AT = 0.62; // 값 레이블 카운트업 시작(마지막 막대 착지 직후)
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);
}
// 감속 이징 — 1 - (1-t)^3. 처음에 빨랐다가 목표에서 미끄러지듯 멈춘다
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
var plotW = W - PAD_L - PAD_R;
var plotH = H - PAD_T - PAD_B;
var baseY = PAD_T + plotH;
var n = ROWS.length;
var slot = plotW / n;
var barW = Math.min(44, Math.max(18, slot * 0.62));
// 가로 그리드 — 100/200/300/400 네 줄. 첫 10% 에 아래에서 뜬다
var axisP = Math.min(1, p / 0.10);
ctx.lineWidth = 1;
ctx.font = '600 10px ' + FONT;
ctx.textBaseline = 'middle';
ctx.textAlign = 'right';
for (var g = 100; g <= 400; g += 100) {
var gy = baseY - plotH * (g / 450) + (1 - axisP) * 12;
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;
}
// 막대 다섯 개 — i 번째는 i*STAGGER 에 시작해 GROW 동안 자란다(stagger 파도)
for (var i = 0; i < n; i++) {
var row = ROWS[i];
var cx = PAD_L + slot * (i + 0.5);
var fullH = plotH * (row.v / 450);
// 바닥 트랙 — 다 자랐을 때의 자리를 옅게 미리 깔아 둔다(착지 목표가 보인다)
ctx.fillStyle = C_TRACK;
ctx.fillRect(cx - barW / 2, baseY - fullH, barW, fullH);
var local = Math.max(0, Math.min(1, (p - i * STAGGER) / GROW));
var eased = easeOutCubic(local);
var h = fullH * eased;
var y = baseY - h;
// 막대 — fillRect. 위 4px 를 같은 색의 밝은 단(lift)으로 덮어 둥근 데이터 끝처럼 보이게 한다
if (h > 0) {
ctx.fillStyle = row.c;
ctx.fillRect(cx - barW / 2, y, barW, h);
if (h > 5) {
ctx.fillStyle = row.lift;
ctx.fillRect(cx - barW / 2, y, barW, Math.min(4, h));
}
}
// 착지 순간의 잔물결 — 자라는 동안 막대 꼭대기에 작은 캡이 붙었다가 착지하며 사라진다
if (local > 0 && local < 1) {
ctx.beginPath();
ctx.arc(cx, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = row.c;
ctx.fill();
}
// 채널 이름 — 축 아래. 왼쪽부터 차례로 나타난다
var lp = Math.max(0, Math.min(1, (p - 0.06 - i * STAGGER) / 0.12));
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = lp;
ctx.font = '600 10px ' + FONT;
ctx.fillText(row.name, cx, baseY + 14);
ctx.globalAlpha = 1;
// 값 레이블 — 막대 착지 뒤 자기 차례에 카운트업. 착지 전엔 그리지 않는다
if (local >= 1) {
var cp = Math.max(0, Math.min(1, (p - (i * STAGGER + GROW)) / 0.22));
var shown = Math.round(row.v * easeOutCubic(cp));
ctx.font = '700 11px ' + FONT;
ctx.fillStyle = C_INK;
ctx.fillText(String(shown), cx, y - 7);
}
}
}
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 — 루프 없이 완성 상태(다 자란 막대와 카운트업 끝난 값) 한 장만 그린다
draw(1);
} else {
raf = requestAnimationFrame(frame);
}// BarGrowChart — vanilla 판(run/305/demo/02-chart-canvas-bar-grow/index.html) 의 React 포트.
// 다섯 채널 막대가 stagger 로 시간차를 두고 바닥에서 값까지 easeOutCubic 으로 자라며,
// 착지한 막대부터 값 레이블이 카운트업된다. @keyframes 없음 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BarGrowChart.module.scss";
export type BarGrowChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Row = { name: string; v: number; cvar: string; liftvar: string };
// 고정 시드 데이터 — 다섯 채널 분기 매출(만원). 색은 CSS 변수 이름으로 달아둔다(그릴 때 읽는다)
const ROWS: Row[] = [
{ name: "검색", v: 420, cvar: "--chart-1", liftvar: "--chart-1" },
{ name: "소셜", v: 310, cvar: "--chart-2", liftvar: "--chart-2-lift" },
{ name: "이메일", v: 265, cvar: "--chart-3", liftvar: "--chart-3-lift" },
{ name: "직접", v: 195, cvar: "--chart-4", liftvar: "--chart-4-lift" },
{ name: "파트너", v: 140, cvar: "--chart-2", liftvar: "--chart-2-lift" }
];
const PAD_L = 52, PAD_R = 14, PAD_T = 20, PAD_B = 26;
const LOOP = 2; // 한 사이클 2초
const GROW = 0.46; // 막대 하나가 자라는 구간
const STAGGER = 0.075; // 오른쪽 막대가 늦게 시작하는 간격
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "막대 그래프 성장 실험판";
const CV_LABEL = "다섯 채널 매출 막대 그래프 — 막대가 바닥에서 값까지 자라는 모습";
export default function BarGrowChart({
duration = "2s",
easing = "ease-out",
color = "#2f6df6",
}: BarGrowChartProps) {
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);
}
// 감속 이징 — 1 - (1-t)^3
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const C_INK = cssv("--ink");
const C_MUTE = cssv("--mute");
const C_GRID = cssv("--grid");
const C_TRACK = cssv("--track");
const C_PAPER = cssv("--paper");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
const plotW = W - PAD_L - PAD_R;
const plotH = H - PAD_T - PAD_B;
const baseY = PAD_T + plotH;
const n = ROWS.length;
const slot = plotW / n;
const barW = Math.min(30, Math.max(14, slot * 0.42));
// 가로 그리드 — 첫 10% 에 아래에서 뜬다
const axisP = Math.min(1, p / 0.10);
ctx.lineWidth = 1;
ctx.font = "600 10px " + FONT;
ctx.textBaseline = "middle";
ctx.textAlign = "right";
for (let g = 100; g <= 400; g += 100) {
const gy = baseY - plotH * (g / 450) + (1 - axisP) * 12;
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;
}
// 막대 — i 번째는 i*STAGGER 에 시작해 GROW 동안 자란다
for (let i = 0; i < n; i++) {
const row = ROWS[i];
const cx = PAD_L + slot * (i + 0.5);
const fullH = plotH * (row.v / 450);
const rowC = row.cvar === "--chart-1" ? (cssv(row.cvar) || color) : cssv(row.cvar);
// 바닥 트랙 — 착지 목표 자리를 옅게 깐다
ctx.fillStyle = C_TRACK;
ctx.fillRect(cx - barW / 2, baseY - fullH, barW, fullH);
const local = Math.max(0, Math.min(1, (p - i * STAGGER) / GROW));
const eased = easeOutCubic(local);
const h = fullH * eased;
const y = baseY - h;
// 막대 — fillRect. 위 4px 를 밝은 단(lift)으로 덮어 둥근 데이터 끝처럼 보이게 한다
if (h > 0) {
ctx.fillStyle = rowC;
ctx.fillRect(cx - barW / 2, y, barW, h);
if (h > 5) {
ctx.fillStyle = cssv(row.liftvar);
ctx.fillRect(cx - barW / 2, y, barW, Math.min(4, h));
}
}
// 자라는 동안 막대 꼭대기 캡
if (local > 0 && local < 1) {
ctx.beginPath();
ctx.arc(cx, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = rowC;
ctx.fill();
}
// 채널 이름 — 왼쪽부터 차례로
const lp = Math.max(0, Math.min(1, (p - 0.06 - i * STAGGER) / 0.12));
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = lp;
ctx.font = "600 10px " + FONT;
ctx.fillText(row.name, cx, baseY + 14);
ctx.globalAlpha = 1;
// 값 레이블 — 착지 뒤 자기 차례에 카운트업
if (local >= 1) {
const cp = Math.max(0, Math.min(1, (p - (i * STAGGER + GROW)) / 0.22));
const shown = Math.round(row.v * easeOutCubic(cp));
ctx.font = "700 11px " + FONT;
ctx.fillStyle = C_INK;
ctx.fillText(String(shown), cx, y - 7);
}
}
}
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="M208 40v168h-56V40Z" opacity=".2"/><path d="M224 200h-8V40a8 8 0 0 0-8-8h-56a8 8 0 0 0-8 8v40H96a8 8 0 0 0-8 8v40H48a8 8 0 0 0-8 8v64h-8a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16M160 48h40v152h-40Zm-56 48h40v104h-40Zm-48 48h32v56H56Z"/></g></svg>
<span>다섯 채널 막대가 시간차로 자라 파도가 됩니다 — <code>stagger</code>·<code>easeOutCubic</code>·<code>fillRect</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>채널별 분기 매출(단위: 만원)</caption>
<thead><tr><th scope="col">채널</th><th scope="col">매출</th></tr></thead>
<tbody>
{ROWS.map((r, i) => (
<tr key={i}><th scope="row">{r.name}</th><td>{r.v}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}02 막대 그래프 성장 — 측정 누적면적 14.631% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종
이 데모가 속한 글
canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종canvas 그래프 그리기는 데이터 배열을 x·y 좌표로 바꿔 캔버스 위에 선·막대·원호로 얹는 일입니다. 라이브러리와 CDN 없이 값이 그려지고 차오르고 모여드는 차트 아홉 종을 만들었고, 파일만 열면 복사한 그대 이 글의 데모 전부 보기


