05Area chart rise
Two series fill upward through a rising clip rect, and in crossing months the leading series brightens; when the fill lands an average line is ruled across them.
Best forActual versus target, cumulative trend
globalAlphaclipquadraticCurveTo

<main class="stage">
<div class="ar" id="ar">
<p class="ar__hint">
<svg class="ar__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>Two series areas rise from the floor; in crossing months the upper series brightens — <code>clip</code>·<code>quadraticCurveTo</code>·<code>Average line</code></span>
</p>
<div class="ar__field" id="field" role="application" aria-label="Area fill test board" tabindex="0">
<canvas class="ar__cv" id="cv" role="img" aria-label="12-month visits/orders area chart — fills rising from the bottom, crossing months brightening the upper series, and a late average line crossing through"></canvas>
</div>
<div class="ar__bar">
<button class="ar__btn" id="replay" type="button">
<svg class="ar__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-month visits and orders (unit: people)</caption>
<thead><tr><th scope="col">Mon</th><th scope="col">Visits</th><th scope="col">Orders</th></tr></thead>
<tbody>
<tr><th scope="row">Jan</th><td>210</td><td>96</td></tr>
<tr><th scope="row">Feb</th><td>248</td><td>118</td></tr>
<tr><th scope="row">Mar</th><td>226</td><td>142</td></tr>
<tr><th scope="row">Apr</th><td>292</td><td>176</td></tr>
<tr><th scope="row">May</th><td>334</td><td>208</td></tr>
<tr><th scope="row">Jun</th><td>306</td><td>236</td></tr>
<tr><th scope="row">Jul</th><td>368</td><td>272</td></tr>
<tr><th scope="row">Aug</th><td>412</td><td>308</td></tr>
<tr><th scope="row">Sep</th><td>384</td><td>344</td></tr>
<tr><th scope="row">Oct</th><td>446</td><td>386</td></tr>
<tr><th scope="row">Nov</th><td>482</td><td>424</td></tr>
<tr><th scope="row">Dec</th><td>456</td><td>468</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 영역 채움 — 움직임은 전부 캔버스 rAF 루프 안(아래→위 clip 리빌 + 면 높이 감속 + 교차 리프트 + 평균선).
// CSS 애니메이션이 0개여야 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 무대는 $stage-paper(흰) — 차트는 밝은 무대가 정석. 두 계열은 차트 토큰 고정 순서 1·2번.
$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;
}
.ar {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다. vivid 리터럴은 두지 않는다(BD26)
--chart-1: #{$chart-1};
--chart-2: #{$chart-2};
--fill-1: #{rgba($chart-1, .12)};
--fill-2: #{rgba($chart-2, .12)};
// 교차 구간 리프트 — 위 계열의 면이 밝아지는 톤. 채움색 한 단 위
--fill-1-lift: #{rgba(color.mix(#ffffff, $chart-1, 18%), .34)};
--fill-2-lift: #{rgba(color.mix(#ffffff, $chart-2, 16%), .34)};
--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;
}
.ar__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;
}
.ar__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;
}
.ar__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.ar__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);
}
.ar__cv { display: block; width: 100%; height: 100%; }
.ar__bar {
display: flex; align-items: center; gap: $sp-2;
}
.ar__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;
}
.ar__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.ar__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.ar { gap: $sp-1; }
.ar__field { height: 112px; }
.ar__hint { font-size: 10px; }
.ar__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 완성 상태 한 장이 남으므로 "다시 그리기"는 여전히 정지 이미지를 다시 그린다.
@media (prefers-reduced-motion: reduce) {
.ar__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;
}
.ar {
--chart-1: #2f6df6;
--chart-2: #c2361a;
--fill-1: rgba(47, 109, 246, 0.12);
--fill-2: rgba(194, 54, 26, 0.12);
--fill-1-lift: rgba(33.1137254902%, 53.0509803922%, 97.1058823529%, 0.34);
--fill-2-lift: rgba(79.9058823529%, 33.7882352941%, 24.5647058824%, 0.34);
--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;
}
.ar__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;
}
.ar__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;
}
.ar__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.ar__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);
}
.ar__cv {
display: block;
width: 100%;
height: 100%;
}
.ar__bar {
display: flex;
align-items: center;
gap: 8px;
}
.ar__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;
}
.ar__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ar__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.ar {
gap: 4px;
}
.ar__field {
height: 112px;
}
.ar__hint {
font-size: 10px;
}
.ar__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ar__btn {
cursor: default;
}
}// 영역 채움 — 12개월 두 계열(Visits·Orders)의 면이 바닥에서 위로 차오른다.
// 세 겹이 함께 움직인다:
// 1) 클리핑 사각형이 아래에서 위로 열린다(01 의 좌→우 clip 과 축만 다른 같은 기법).
// 2) 면의 꼭짓점 높이를 같은 진행률로 (Value-바닥)*s+바닥 으로 눌러, 클립과 면이 함께 위로 차오르는
// 키가 큰 면을 만든다(누적면적). 곡선은 quadraticCurveTo 중점 제어로 부드럽게.
// 3) 두 계열이 교차하는 달(11~Dec)은 채움이 그 달에 닿은 순간부터 위 계열의 면을 리프트 톤(한 단 밝게)으로.
// 채움이 끝나면(0.85) 두 계열 평균의 수평선이 왼쪽에서 오른쪽으로 그 위를 가로질러 긋는다(--ink 점선).
var root = document.getElementById('ar');
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_1F = css.getPropertyValue('--fill-1').trim();
var C_2F = css.getPropertyValue('--fill-2').trim();
var C_1L = css.getPropertyValue('--fill-1-lift').trim();
var C_2L = css.getPropertyValue('--fill-2-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_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 고정 시드 데이터 — 손으로 쓴 배열이라 다시 그려도 같은 그림.
// 연말(Dec)에 주문이 방문을 앞지른다 — 11→Dec 구간이 교차 구간이 되어 리프트가 목격된다
var A = [210, 248, 226, 292, 334, 306, 368, 412, 384, 446, 482, 456]; // Visits(계열 1)
var B = [ 96, 118, 142, 176, 208, 236, 272, 308, 344, 386, 424, 468]; // Orders(계열 2)
var MONTHS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
var PAD_L = 40, PAD_R = 16, PAD_T = 18, PAD_B = 30; // Axis 라벨 여백(CSS px)
var LOOP = 2; // 한 사이클 2초 — 캡처 창과 같아 gif 가 이어진다
var FILL_AT = 0.85; // 전체 2초의 85% 동안 차오르고, 그 뒤 평균선이 그 위를 지나간다
var LINE_AT = 0.93; // Average line 그리기가 끝나는 지점 — 이후 확정 상태로 쉰다
var Y_TOP = 50, Y_SPAN = 500; // y Axis 스케일 고정(50~500명)
// 두 계열의 전 기간 Average — Average line(확정선)의 y Value
var sumA = 0, sumB = 0;
for (var q = 0; q < 12; q++) { sumA += A[q]; sumB += B[q]; }
var AVG_A = Math.round(sumA / 12);
var AVG_B = Math.round(sumB / 12);
var AVG = Math.round((AVG_A + AVG_B) / 2);
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);
}
// 데이터 인덱스 → 플롯 x. 월은 12칸 균등
function xAt(i) { return PAD_L + (W - PAD_L - PAD_R) * (i / (A.length - 1)); }
// 데이터 Value → 플롯 y. 위로 갈수록 크다
function yAt(v) { return PAD_T + (H - PAD_T - PAD_B) * (1 - (v - Y_TOP) / (Y_SPAN - Y_TOP)); }
// 채움 진행 s(0..1) 가 누른 꼭짓점 높이 — 바닥(Y_TOP) 기준 (v-바닥)*s+바닥. s=1 이면 제값
function vAt(v, s) { return Y_TOP + (v - Y_TOP) * s; }
// 계열의 윗가장자리 곡선 — 꼭짓점을 중점 제어로 눌러 부드럽게 이은 열린 경로
function linePath(S, s) {
var n = S.length;
ctx.beginPath();
ctx.moveTo(xAt(0), yAt(vAt(S[0], s)));
for (var j = 1; j < n - 1; j++) {
ctx.quadraticCurveTo(xAt(j), yAt(vAt(S[j], s)),
(xAt(j) + xAt(j + 1)) / 2, (yAt(vAt(S[j], s)) + yAt(vAt(S[j + 1], s))) / 2);
}
ctx.lineTo(xAt(n - 1), yAt(vAt(S[n - 1], s)));
}
// 면 경로 — 윗가장자리 곡선을 바닥까지 닫는다
function areaPath(S, s) {
linePath(S, s);
ctx.lineTo(xAt(S.length - 1), H - PAD_B);
ctx.lineTo(xAt(0), H - PAD_B);
ctx.closePath();
}
// 감속 이징 — 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);
// 가로 그리드 + 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 = 100; 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 라벨(Mon) — 축이 뜬 뒤 왼쪽부터 차례로 나타난다
ctx.textAlign = 'center';
for (var m = 0; m < MONTHS.length; m++) {
var mp = Math.max(0, Math.min(1, (p - 0.10 - m * 0.018) / 0.10));
if (mp <= 0) continue;
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = mp;
ctx.fillText(MONTHS[m], xAt(m), H - PAD_B + 13);
ctx.globalAlpha = 1;
}
// 채움 진행률 — 감속 이징 하나로 클립과 면 높이를 함께 누른다
var f = easeOutCubic(Math.min(1, p / FILL_AT));
var s = f * f; // 면 높이 — 시작이 급하고 끝에 붙는 감
// 클리핑 사각형 — 바닥에서 위로 열린다. 이 안에서만 면과 선이 보인다
var cutY = (H - PAD_B) - (H - PAD_T - PAD_B) * f;
ctx.save();
ctx.beginPath();
ctx.rect(0, cutY - 1, W, H);
ctx.clip();
// 계열 2(Orders) 면 — 아래 깔리는 쪽. 채움 내내 채워진다
areaPath(B, s);
ctx.fillStyle = C_2F;
ctx.fill();
// 계열 1(Visits) 면 — 위에 겹친다
areaPath(A, s);
ctx.fillStyle = C_1F;
ctx.fill();
// 교차 리프트 — 두 계열의 순서가 바뀌는 구간에서, 채움이 닿은 순간부터
// 그 달 구간의 위 계열 면을 리프트 톤(한 단 밝게)으로 다시 깐다
var reachI = Math.floor(Math.min(1, p / FILL_AT) * (A.length - 1)); // 채움이 닿은 마지막 달
for (var c = 0; c < A.length - 1; c++) {
if (c > reachI) break; // 아직 채움이 닿지 않은 달은 두지 않는다
var topNow = A[c + 1] >= B[c + 1] ? 1 : 2; // 구간 끝 월에서 위에 있는 계열
var topPrev = A[c] >= B[c] ? 1 : 2; // 구간 시작 월에서 위에 있는 계열
if (topNow === topPrev) continue; // 교차가 없는 구간은 그대로
var S2 = topNow === 1 ? A : B;
var lift = topNow === 1 ? C_1L : C_2L;
ctx.save();
ctx.beginPath();
ctx.rect(xAt(c), 0, xAt(c + 1) - xAt(c) + 1, H); // 그 달 구간만
ctx.clip();
areaPath(S2, s);
ctx.fillStyle = lift;
ctx.globalAlpha = 0.4;
ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
}
// 계열 2 윤곽선 — 면 위 가장자리만 얇게(2px. 마크 규격)
ctx.strokeStyle = C_2;
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
linePath(B, s);
ctx.stroke();
// 계열 1 윤곽선
ctx.strokeStyle = C_1;
linePath(A, s);
ctx.stroke();
ctx.restore();
// 범례 — 계열 1·2 색점과 이름. 왼쪽 위(초반 저값 구간)에 채움과 함께 뜬다
if (f > 0.15) {
ctx.globalAlpha = Math.min(1, (f - 0.15) / 0.2);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '700 10px ' + FONT;
ctx.beginPath();
ctx.arc(PAD_L + 8, PAD_T + 8, 3.5, 0, Math.PI * 2);
ctx.fillStyle = C_1;
ctx.fill();
ctx.fillStyle = C_INK;
ctx.fillText('Visits', PAD_L + 16, PAD_T + 8);
ctx.beginPath();
ctx.arc(PAD_L + 52, PAD_T + 8, 3.5, 0, Math.PI * 2);
ctx.fillStyle = C_2;
ctx.fill();
ctx.fillStyle = C_INK;
ctx.fillText('Orders', PAD_L + 60, PAD_T + 8);
ctx.globalAlpha = 1;
}
// Average line — 채움 완료(0.85) 뒤 왼쪽에서 오른쪽으로 그 위를 가로질러 긋는다(--ink 점선)
if (p > FILL_AT) {
var lp = Math.min(1, (p - FILL_AT) / (LINE_AT - FILL_AT));
var ly = yAt(AVG);
var lx = PAD_L + (W - PAD_L - PAD_R) * easeOutCubic(lp);
ctx.beginPath();
ctx.moveTo(PAD_L, ly);
ctx.lineTo(lx, ly);
ctx.strokeStyle = C_INK;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.setLineDash([]);
// Average 라벨 — 선 끝에 붙어 따라간다
ctx.font = '700 10px ' + FONT;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = C_INK;
ctx.fillText('Average ' + AVG, Math.min(W - PAD_R - 52, PAD_L + 6), ly - 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);
}// AreaRiseChart — vanilla 판(run/305/demo/05-chart-canvas-area-rise/index.html) 의 React 포트.
// 두 계열의 면이 클리핑 사각형(아래→위)과 면 높이 감속으로 바닥에서 위로 차오르고,
// 교차하는 달은 위 계열의 면이 리프트 톤으로 밝아지며, 채움 뒤 평균선이 가로지른다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./AreaRiseChart.module.scss";
export type AreaRiseChartProps = {
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 A = [210, 248, 226, 292, 334, 306, 368, 412, 384, 446, 482, 456]; // 방문(계열 1)
const B = [96, 118, 142, 176, 208, 236, 272, 308, 344, 386, 424, 468]; // 주문(계열 2)
const MONTHS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
const PAD_L = 40, PAD_R = 16, PAD_T = 18, PAD_B = 30;
const LOOP = 2; // 한 사이클 2초
const FILL_AT = 0.85; // 전체의 85% 동안 차오르고 그 뒤 평균선이 지나간다
const LINE_AT = 0.93; // 평균선 그리기가 끝나는 지점
const Y_TOP = 50, Y_SPAN = 500;
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "영역 채움 실험판";
const CV_LABEL = "12개월 방문·주문 두 계열 영역 그래프 — 채움이 아래에서 위로 차오르고 교차하는 달은 위 계열이 밝아지며, 뒤늦게 평균선이 가로지르는 모습";
// 두 계열의 전 기간 평균 — 평균선(확정선)의 값
const AVG_A = Math.round(A.reduce((s, v) => s + v, 0) / A.length);
const AVG_B = Math.round(B.reduce((s, v) => s + v, 0) / B.length);
const AVG = Math.round((AVG_A + AVG_B) / 2);
export default function AreaRiseChart({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#2f6df6", // vanilla $color: $chart-1
}: AreaRiseChartProps) {
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);
}
// 데이터 인덱스 → 플롯 x. 월은 12칸 균등
function xAt(i: number): number {
const W = sizeRef.current.w;
return PAD_L + (W - PAD_L - PAD_R) * (i / (A.length - 1));
}
// 데이터 값 → 플롯 y. 위로 갈수록 크다
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));
}
// 채움 진행 s(0..1) 가 누른 꼭짓점 높이 — 바닥(Y_TOP) 기준 (v-바닥)*s+바닥. s=1 이면 제값
function vAt(v: number, s: number): number {
return Y_TOP + (v - Y_TOP) * s;
}
// 계열의 윗가장자리 곡선 — 꼭짓점을 중점 제어로 눌러 부드럽게 이은 열린 경로
function linePath(S: number[], s: number): void {
const ctx = ctxRef.current!;
const n = S.length;
ctx.beginPath();
ctx.moveTo(xAt(0), yAt(vAt(S[0], s)));
for (let j = 1; j < n - 1; j++) {
ctx.quadraticCurveTo(xAt(j), yAt(vAt(S[j], s)),
(xAt(j) + xAt(j + 1)) / 2, (yAt(vAt(S[j], s)) + yAt(vAt(S[j + 1], s))) / 2);
}
ctx.lineTo(xAt(n - 1), yAt(vAt(S[n - 1], s)));
}
// 면 경로 — 윗가장자리 곡선을 바닥까지 닫는다
function areaPath(S: number[], s: number): void {
const ctx = ctxRef.current!;
linePath(S, s);
ctx.lineTo(xAt(S.length - 1), sizeRef.current.h - PAD_B);
ctx.lineTo(xAt(0), sizeRef.current.h - PAD_B);
ctx.closePath();
}
// 감속 이징 — 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_1 = cssv("--chart-1") || color;
const C_2 = cssv("--chart-2");
const C_1F = cssv("--fill-1");
const C_2F = cssv("--fill-2");
const C_1L = cssv("--fill-1-lift");
const C_2L = cssv("--fill-2-lift");
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 라벨 — 100~500 다섯 줄. 첫 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 = 100; 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 m = 0; m < MONTHS.length; m++) {
const mp = Math.max(0, Math.min(1, (p - 0.10 - m * 0.018) / 0.10));
if (mp <= 0) continue;
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = mp;
ctx.fillText(MONTHS[m], xAt(m), H - PAD_B + 13);
ctx.globalAlpha = 1;
}
// 채움 진행률 — 감속 이징 하나로 클립과 면 높이를 함께 누른다
const f = easeOutCubic(Math.min(1, p / FILL_AT));
const s = f * f; // 면 높이 — 시작이 급하고 끝에 붙는 감
// 클리핑 사각형 — 바닥에서 위로 열린다. 이 안에서만 면과 선이 보인다
const cutY = (H - PAD_B) - (H - PAD_T - PAD_B) * f;
ctx.save();
ctx.beginPath();
ctx.rect(0, cutY - 1, W, H);
ctx.clip();
// 계열 2(주문) 면 — 아래 깔리는 쪽
areaPath(B, s);
ctx.fillStyle = C_2F;
ctx.fill();
// 계열 1(방문) 면 — 위에 겹친다
areaPath(A, s);
ctx.fillStyle = C_1F;
ctx.fill();
// 교차 리프트 — 두 계열의 순서가 바뀌는 구간에서, 채움이 닿은 순간부터
// 그 달 구간의 위 계열 면을 리프트 톤(한 단 밝게)으로 다시 깐다
const reachI = Math.floor(Math.min(1, p / FILL_AT) * (A.length - 1)); // 채움이 닿은 마지막 달
for (let c = 0; c < A.length - 1; c++) {
if (c > reachI) break; // 아직 채움이 닿지 않은 달은 두지 않는다
const topNow = A[c + 1] >= B[c + 1] ? 1 : 2; // 구간 끝 월에서 위에 있는 계열
const topPrev = A[c] >= B[c] ? 1 : 2; // 구간 시작 월에서 위에 있는 계열
if (topNow === topPrev) continue; // 교차가 없는 구간은 그대로
const S2 = topNow === 1 ? A : B;
const lift = topNow === 1 ? C_1L : C_2L;
ctx.save();
ctx.beginPath();
ctx.rect(xAt(c), 0, xAt(c + 1) - xAt(c) + 1, H); // 그 달 구간만
ctx.clip();
areaPath(S2, s);
ctx.fillStyle = lift;
ctx.globalAlpha = 0.4;
ctx.fill();
ctx.globalAlpha = 1;
ctx.restore();
}
// 계열 2 윤곽선 — 면 위 가장자리만 얇게(2px. 마크 규격)
ctx.strokeStyle = C_2;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.lineCap = "round";
linePath(B, s);
ctx.stroke();
// 계열 1 윤곽선
ctx.strokeStyle = C_1;
linePath(A, s);
ctx.stroke();
ctx.restore();
// 범례 — 계열 1·2 색점과 이름. 왼쪽 위(초반 저값 구간)에 채움과 함께 뜬다
if (f > 0.15) {
ctx.globalAlpha = Math.min(1, (f - 0.15) / 0.2);
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.font = "700 10px " + FONT;
ctx.beginPath();
ctx.arc(PAD_L + 8, PAD_T + 8, 3.5, 0, Math.PI * 2);
ctx.fillStyle = C_1;
ctx.fill();
ctx.fillStyle = C_INK;
ctx.fillText("방문", PAD_L + 16, PAD_T + 8);
ctx.beginPath();
ctx.arc(PAD_L + 52, PAD_T + 8, 3.5, 0, Math.PI * 2);
ctx.fillStyle = C_2;
ctx.fill();
ctx.fillStyle = C_INK;
ctx.fillText("주문", PAD_L + 60, PAD_T + 8);
ctx.globalAlpha = 1;
}
// 평균선 — 채움 완료(0.85) 뒤 왼쪽에서 오른쪽으로 그 위를 가로질러 긋는다(--ink 점선)
if (p > FILL_AT) {
const lp = Math.min(1, (p - FILL_AT) / (LINE_AT - FILL_AT));
const ly = yAt(AVG);
const lx = PAD_L + (W - PAD_L - PAD_R) * easeOutCubic(lp);
ctx.beginPath();
ctx.moveTo(PAD_L, ly);
ctx.lineTo(lx, ly);
ctx.strokeStyle = C_INK;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.setLineDash([]);
// 평균 라벨 — 선 끝에 붙어 따라간다
ctx.font = "700 10px " + FONT;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = C_INK;
ctx.fillText("평균 " + AVG, Math.min(W - PAD_R - 52, PAD_L + 6), ly - 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="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>두 계열의 영역이 바닥에서 위로 차오르고 교차하는 달은 위 계열이 밝아집니다 — <code>clip</code>·<code>quadraticCurveTo</code>·<code>평균선</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><th scope="col">주문수</th></tr></thead>
<tbody>
{A.map((v, i) => (
<tr key={i}><th scope="row">{i + 1}월</th><td>{v}</td><td>{B[i]}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}05 Area chart rise — Measured area 25.02% · 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 UI07 Stacked area chart filling from belowCSS Chart Design: 9 SVG Graphs, No Library
Dashboard UI02 Waterfall Bridge Chart Connecting Each Rise and Fall9 Data Visualization Chart Types With Real Math
Dashboard UI04 Donut chart card with center counterDashboard Sidebar Design + 9 Widget UI Pieces