09히트맵 파동
7일×8시간 셀들이 왼쪽 위부터 파도 타이밍으로 하나씩 채워지고, 색은 계량(scale)을 통과해 파스텔 다섯 단계로 매핑된다. 셀에 마우스를 올리면 날짜와 값이 뜬다.
쓰임방문 시간대·사용 패턴 탐색
scalergbapointermove

<main class="stage">
<div class="hw" id="hw">
<p class="hw__hint">
<svg class="hw__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V64a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M111 51h-4V32a16 16 0 0 1 32 0v19h-4a102.63 102.63 0 0 1 13 18h23a16 16 0 0 1 16 16v23a102.63 102.63 0 0 1 18 13v4h19a16 16 0 0 1 0 32h-19v4a102.63 102.63 0 0 1-18 13v23a16 16 0 0 1-16 16h-23a102.63 102.63 0 0 1-13 18h4v19a16 16 0 0 1-32 0v-19h4a102.63 102.63 0 0 1-13-18H75a16 16 0 0 1-16-16v-23a102.63 102.63 0 0 1-18-13v-4a102.63 102.63 0 0 1 0-32v-4a102.63 102.63 0 0 1 18-13V85a16 16 0 0 1 16-16h23a102.63 102.63 0 0 1 13-18m-34 34v86h102V85Zm51 75a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg>
<span>56칸이 대각선 파도를 그리며 채워집니다 — <code>sin 위상</code>·<code>5단 농도 ramp</code>·<code>캔버스 툴팁</code></span>
</p>
<div class="hw__field" id="field" role="application" aria-label="히트맵 파도 실험판" tabindex="0">
<canvas class="hw__cv" id="cv" role="img" aria-label="요일×시간대 방문 밀도 히트맵 — 셀이 왼쪽 위에서 대각선 파도로 채워지고 평균과 최고 셀이 뜬다"></canvas>
</div>
<div class="hw__bar">
<button class="hw__btn" id="replay" type="button">
<svg class="hw__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>요일×시간대 방문 밀도(0~100) — 시간대 둘씩 묶은 요약</caption>
<thead><tr><th scope="col">요일</th><th scope="col">09~10시</th><th scope="col">11~12시</th><th scope="col">13~14시</th><th scope="col">15~16시</th></tr></thead>
<tbody>
<tr><th scope="row">월</th><td>28</td><td>50</td><td>41</td><td>24</td></tr>
<tr><th scope="row">화</th><td>32</td><td>58</td><td>46</td><td>27</td></tr>
<tr><th scope="row">수</th><td>30</td><td>61</td><td>50</td><td>29</td></tr>
<tr><th scope="row">목</th><td>26</td><td>61</td><td>54</td><td>32</td></tr>
<tr><th scope="row">금</th><td>23</td><td>61</td><td>59</td><td>37</td></tr>
<tr><th scope="row">토</th><td>48</td><td>72</td><td>86</td><td>67</td></tr>
<tr><th scope="row">일</th><td>57</td><td>80</td><td>88</td><td>59</td></tr>
</tbody>
</table>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
// 히트맵 파도 — 움직임은 전부 캔버스 rAF 루프 안(대각선 파도 타이밍 셀 채움 + 캔버스 안 툴팁).
// CSS 애니메이션 0개 — 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 농도 ramp 는 한 색 계열의 5단(밝은 파스텔 → 차트 블루 → 잉크 곁 파랑) — 순차 스케일이라
// 색상 변별이 필요 없고 밝기만 읽으면 된다(dataviz 순차 스케일 원칙, 297 계열과 같은 판단).
// 숫자 라벨은 칩 색 위가 아니라 흰 무대 배경 위에 그린다(대비).
$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;
}
.hw {
// 캔버스가 읽는 색 — JS 가 getComputedStyle 로 꺼내 쓴다. vivid 리터럴은 두지 않는다(BD26).
// 5단 농도 ramp: 값 0~19 는 1(가장 옅음), 80~100 은 5(가장 짙음)
--heat-1: #{color.mix(#ffffff, $chart-1, 82%)};
--heat-2: #{color.mix(#ffffff, $chart-1, 58%)};
--heat-3: #{color.mix(#ffffff, $chart-1, 26%)};
--heat-4: #{$chart-1};
--heat-5: #{color.mix($subject-ink, $chart-1, 30%)};
--ink: #{$subject-ink};
--mute: #{rgba($subject-ink, .62)};
--paper: #{$stage-paper};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.hw__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;
}
.hw__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;
}
.hw__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.hw__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);
}
.hw__cv { display: block; width: 100%; height: 100%; }
.hw__bar {
display: flex; align-items: center; gap: $sp-2;
}
.hw__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;
}
.hw__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.hw__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.hw { gap: $sp-1; }
.hw__field { height: 112px; }
.hw__hint { font-size: 10px; }
.hw__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끌 애니메이션이 없다. 완성 상태 한 장만 남는다.
// hover 툴팁은 reduce 에서도 반응한다(마우스 이벤트는 애니메이션이 아니다).
@media (prefers-reduced-motion: reduce) {
.hw__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;
}
.hw {
--heat-1: rgb(85.3176470588%, 89.6941176471%, 99.3647058824%);
--heat-2: rgb(65.7411764706%, 75.9529411765%, 98.5176470588%);
--heat-3: rgb(39.6392156863%, 57.631372549%, 97.3882352941%);
--heat-4: #2f6df6;
--heat-5: rgb(15.6078431373%, 32.2745098039%, 70.5882352941%);
--ink: #17141a;
--mute: rgba(23, 20, 26, 0.62);
--paper: #ffffff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.hw__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;
}
.hw__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;
}
.hw__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.hw__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);
}
.hw__cv {
display: block;
width: 100%;
height: 100%;
}
.hw__bar {
display: flex;
align-items: center;
gap: 8px;
}
.hw__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;
}
.hw__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.hw__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.hw {
gap: 4px;
}
.hw__field {
height: 112px;
}
.hw__hint {
font-size: 10px;
}
.hw__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.hw__btn {
cursor: default;
}
}// 히트맵 파도 — 7일×8시간(56셀) 그리드가 왼쪽 위부터 대각선 파도로 채워진다.
// 셀(i,j) 의 등장 시각 = (i+j)*계수 + 사인 위상 — 같은 대각선끼리 병행하고 위상이 살짝 어긋나
// 순차+병행이 섞인 파도가 된다. 색은 값(0~100)을 5단 농도 ramp(--heat-1..--heat-5)로 얹는다.
// hover(mousemove)하면 셀 좌표를 계산해 요일·시간대·값 툴팁을 캔버스 안에 그린다(DOM 아님).
// 채워진 뒤(0.8) 평균·최고 셀 라벨이 뜬다. 숫자 라벨은 칩 색 위가 아니라 흰 무대 배경 위(297 계열 — 대비).
var root = document.getElementById('hw');
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 HEAT = [
css.getPropertyValue('--heat-1').trim(),
css.getPropertyValue('--heat-2').trim(),
css.getPropertyValue('--heat-3').trim(),
css.getPropertyValue('--heat-4').trim(),
css.getPropertyValue('--heat-5').trim()
];
var C_INK = css.getPropertyValue('--ink').trim();
var C_MUTE = css.getPropertyValue('--mute').trim();
var C_PAPER = css.getPropertyValue('--paper').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 고정 시드 — 7일(월~일)×8시간대(09~16시) 방문 밀도(0~100). 난수가 아니라 코드 안 배열
var DAYS = ['월', '화', '수', '목', '금', '토', '일'];
var HOURS = ['09', '10', '11', '12', '13', '14', '15', '16'];
var GRID = [
[22, 34, 48, 52, 44, 38, 30, 18],
[26, 38, 55, 60, 50, 42, 34, 20],
[24, 36, 58, 64, 54, 46, 36, 22],
[20, 32, 54, 68, 58, 50, 40, 24],
[18, 28, 50, 72, 62, 56, 46, 28],
[42, 54, 66, 78, 84, 88, 76, 58],
[52, 62, 74, 86, 92, 84, 70, 48]
];
var ROWS = DAYS.length, COLS = HOURS.length;
var LOOP = 2.4; // 한 사이클 2.4초 — 캡처 창(2초) 내내 파동이 지나가는 중이다
var FILL_END = 0.56; // 파도가 마지막 셀까지 채우는 지점
var DIAG = 0.045; // 대각선 계수 — (i+j) 마다 등장 시각이 이만큼 늦춰진다
var WAVE = 0.06; // 사인 위상 폭 — 같은 대각선 안에서도 어긋나게
var GROW = 0.10; // 셀 하나가 0에서 1로 차오르는 구간
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var t0 = 0, raf = 0;
var hover = null; // { di, hi } — 현재 hover 셀. 없으면 null
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);
}
// 감속 이징 — 셀이 차오를 때 미끄러지듯 멈춘다
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
// 값(0~100) → 5단 농도 ramp 인덱스(0..4). 20점 간격 계량(scale)
function rampAt(v) {
return Math.min(4, Math.floor(v / 20));
}
// 셀(i,j) 의 채워짐 진행(0..1) — 대각선 파도: (i+j)*계수 + 사인 위상
function cellAt(i, j, p) {
var phase = Math.sin((i * 3.7 + j * 2.9)) * WAVE; // 셀마다 정해진 위상 어긋남(시드)
var start = (i + j) * DIAG + phase;
return Math.max(0, Math.min(1, (p - start) / GROW));
}
// 그리드 지오메트리 — 라벨 여백을 남기고 셀 칸을 잡는다
function geo() {
var padL = 22, padT = 18, padB = 14, padR = 10;
var gw = W - padL - padR;
var gh = H - padT - padB;
return {
x0: padL, y0: padT,
cw: gw / COLS, ch: gh / ROWS,
gap: Math.min(3, (gw + gh) / 220)
};
}
// hover 셀 계산 — mousemove 좌표를 셀 칸으로 되돌린다
function cellFromPoint(mx, my) {
var g = geo();
var cj = Math.floor((mx - g.x0) / g.cw);
var ci = Math.floor((my - g.y0) / g.ch);
if (ci < 0 || ci >= ROWS || cj < 0 || cj >= COLS) return null;
return { di: ci, hi: cj };
}
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
var g = geo();
// 요일 라벨 — 첫 10% 에 뜬다
var rowP = Math.min(1, p / 0.10);
ctx.font = '700 9px ' + FONT;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (var i = 0; i < ROWS; i++) {
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = rowP;
ctx.fillText(DAYS[i], g.x0 - 5, g.y0 + g.ch * (i + 0.5));
ctx.globalAlpha = 1;
}
// 시간 라벨 — 요일 다음 순으로 뜬다
ctx.textAlign = 'center';
for (var j = 0; j < COLS; j++) {
var colP = Math.min(1, Math.max(0, (p - 0.05) / 0.10));
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = colP;
ctx.fillText(HOURS[j], g.x0 + g.cw * (j + 0.5), g.y0 - 6);
ctx.globalAlpha = 1;
}
// 셀 56개 — 값 계량 → 5단 ramp 색. 채워짐 진행만큼 알파와 크기가 찬다
var maxV = 0, maxCell = null, sum = 0, filled = 0;
for (var r = 0; r < ROWS; r++) {
for (var c = 0; c < COLS; c++) {
var gp = easeOutCubic(cellAt(r, c, p));
if (gp <= 0) continue;
var v = GRID[r][c];
if (gp >= 1) {
filled += 1; sum += v;
if (v > maxV) { maxV = v; maxCell = { r: r, c: c }; }
}
var isHover = hover && hover.di === r && hover.hi === c;
var cx = g.x0 + g.cw * c + g.gap / 2 + (g.cw - g.gap) * (1 - gp) / 2; // 중심에서 자라난다
var cy = g.y0 + g.ch * r + g.gap / 2 + (g.ch - g.gap) * (1 - gp) / 2;
var cw = (g.cw - g.gap) * gp;
var ch = (g.ch - g.gap) * gp;
ctx.globalAlpha = 0.30 + 0.70 * gp; // 트랙(0.30)에서 실제 농도로 진해진다
ctx.fillStyle = HEAT[rampAt(v)];
roundRectFill(cx, cy, cw, ch, 4);
ctx.globalAlpha = 1;
// hover 셀 — 흰 테두리 링
if (isHover && gp >= 1) {
ctx.strokeStyle = C_PAPER;
ctx.lineWidth = 2;
roundRectStroke(g.x0 + g.cw * c + g.gap / 2, g.y0 + g.ch * r + g.gap / 2,
g.cw - g.gap, g.ch - g.gap, 4);
}
}
}
// 평균·최고 라벨 — 전부 찬 뒤(0.8) 흰 무대 위에 뜬다(칩 색 위가 아니라 — 297 계열 대비)
if (p > 0.8) {
var lp = Math.min(1, (p - 0.8) / 0.10);
ctx.globalAlpha = lp;
ctx.font = '700 10px ' + FONT;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = C_INK;
ctx.fillText('평균 ' + (filled ? Math.round(sum / filled) : 0), g.x0, H - 3);
ctx.textAlign = 'right';
ctx.fillStyle = C_INK;
if (maxCell) {
ctx.fillText('최고 ' + DAYS[maxCell.r] + ' ' + HOURS[maxCell.c] + '시 ' + maxV, W - 10, H - 3);
}
ctx.globalAlpha = 1;
}
// 캔버스 안 툴팁 — hover 셀의 요일·시간대·값. 셀 위에 흰 카드로 그린다(DOM 아님)
if (hover) {
var hv = GRID[hover.di][hover.hi];
var tx = g.x0 + g.cw * (hover.hi + 0.5);
var ty = g.y0 + g.ch * hover.di;
var label = DAYS[hover.di] + ' ' + HOURS[hover.hi] + '시';
ctx.font = '700 10px ' + FONT;
var lw = Math.max(ctx.measureText(label).width, ctx.measureText('값 ' + hv).width) + 16;
var bx = Math.min(W - lw - 4, Math.max(4, tx - lw / 2));
var by = Math.max(4, ty - 34);
ctx.fillStyle = C_PAPER;
roundRectFill(bx, by, lw, 28, 6);
ctx.strokeStyle = C_MUTE;
ctx.lineWidth = 1;
roundRectStroke(bx, by, lw, 28, 6);
ctx.fillStyle = C_INK;
ctx.textAlign = 'center';
ctx.textBaseline = 'alphabetic';
ctx.fillText(label, bx + lw / 2, by + 12);
ctx.fillStyle = HEAT[rampAt(hv)];
ctx.fillRect(bx + 8, by + 17, 8, 8);
ctx.fillStyle = C_INK;
ctx.textAlign = 'left';
ctx.fillText('값 ' + hv, bx + 20, by + 25);
}
}
// 라운드 사각 채우기 — 히트 셀 반경 4(토큰 $r-xs 와 같은 눈금)
function roundRectFill(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.fill();
}
// 라운드 사각 테두리 — 위 경로에 stroke 만 얹는다
function roundRectStroke(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.stroke();
}
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; });
cv.addEventListener('mousemove', function (e) {
var r = cv.getBoundingClientRect();
hover = cellFromPoint(e.clientX - r.left, e.clientY - r.top);
if (reduce) draw(1);
});
cv.addEventListener('mouseleave', function () {
hover = null;
if (reduce) draw(1);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1); });
fit();
if (reduce) {
// reduce — 루프 없이 완성 상태(56셀 전부 찬 히트맵 + 평균·최고 라벨) 한 장만 그린다
draw(1);
} else {
raf = requestAnimationFrame(frame);
}// HeatmapWaveChart — vanilla 판(run/305/demo/09-chart-canvas-heatmap-wave/index.html) 의 React 포트.
// 7일×8시간(56셀) 그리드가 왼쪽 위부터 대각선 파도로 채워지고 hover 하면 캔버스 안 툴팁이 뜬다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import styles from "./HeatmapWaveChart.module.scss";
export type HeatmapWaveChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// 고정 시드 — 7일(월~일)×8시간대(09~16시) 방문 밀도(0~100). 난수가 아니라 코드 안 배열
const DAYS = ["월", "화", "수", "목", "금", "토", "일"];
const HOURS = ["09", "10", "11", "12", "13", "14", "15", "16"];
const GRID = [
[22, 34, 48, 52, 44, 38, 30, 18],
[26, 38, 55, 60, 50, 42, 34, 20],
[24, 36, 58, 64, 54, 46, 36, 22],
[20, 32, 54, 68, 58, 50, 40, 24],
[18, 28, 50, 72, 62, 56, 46, 28],
[42, 54, 66, 78, 84, 88, 76, 58],
[52, 62, 74, 86, 92, 84, 70, 48]
];
const ROWS = DAYS.length, COLS = HOURS.length;
const LOOP = 2; // 한 사이클 2초
const DIAG = 0.045; // 대각선 계수 — (i+j) 마다 등장 시각이 이만큼 늦춰진다
const WAVE = 0.06; // 사인 위상 폭 — 같은 대각선 안에서도 어긋나게
const GROW = 0.10; // 셀 하나가 0에서 1로 차오르는 구간
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "히트맵 파도 실험판";
const CV_LABEL = "요일×시간대 방문 밀도 히트맵 — 셀이 왼쪽 위에서 대각선 파도로 채워지고 평균과 최고 셀이 뜬다";
export default function HeatmapWaveChart({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#2f6df6", // vanilla $color: $chart-1
}: HeatmapWaveChartProps) {
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 hoverRef = useRef<{ di: number; hi: number } | null>(null) as Box<{ di: number; hi: number } | null>;
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 easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
// 값(0~100) → 5단 농도 ramp 인덱스(0..4). 20점 간격 계량(scale)
function rampAt(v: number): number {
return Math.min(4, Math.floor(v / 20));
}
// 셀(i,j) 의 채워짐 진행(0..1) — 대각선 파도: (i+j)*계수 + 사인 위상
function cellAt(i: number, j: number, p: number): number {
const phase = Math.sin(i * 3.7 + j * 2.9) * WAVE; // 셀마다 정해진 위상 어긋남(시드)
const start = (i + j) * DIAG + phase;
return Math.max(0, Math.min(1, (p - start) / GROW));
}
// 그리드 지오메트리 — 라벨 여백을 남기고 셀 칸을 잡는다
function geo() {
const W = sizeRef.current.w, H = sizeRef.current.h;
const padL = 22, padT = 18, padB = 14, padR = 10;
const gw = W - padL - padR;
const gh = H - padT - padB;
return { x0: padL, y0: padT, cw: gw / COLS, ch: gh / ROWS, gap: Math.min(3, (gw + gh) / 220) };
}
// hover 셀 계산 — mousemove 좌표를 셀 칸으로 되돌린다
function cellFromPoint(mx: number, my: number): { di: number; hi: number } | null {
const g = geo();
const cj = Math.floor((mx - g.x0) / g.cw);
const ci = Math.floor((my - g.y0) / g.ch);
if (ci < 0 || ci >= ROWS || cj < 0 || cj >= COLS) return null;
return { di: ci, hi: cj };
}
// 라운드 사각 경로 — 히트 셀 반경 4(토큰 $r-xs 와 같은 눈금)
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const HEAT = [cssv("--heat-1"), cssv("--heat-2"), cssv("--heat-3"), cssv("--heat-4"), cssv("--heat-5")];
const C_INK = cssv("--ink");
const C_MUTE = cssv("--mute");
const C_PAPER = cssv("--paper");
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
const g = geo();
// 요일 라벨 — 첫 10% 에 뜬다
const rowP = Math.min(1, p / 0.10);
ctx.font = "700 9px " + FONT;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
for (let i = 0; i < ROWS; i++) {
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = rowP;
ctx.fillText(DAYS[i], g.x0 - 5, g.y0 + g.ch * (i + 0.5));
ctx.globalAlpha = 1;
}
// 시간 라벨 — 요일 다음 순으로 뜬다
ctx.textAlign = "center";
for (let j = 0; j < COLS; j++) {
const colP = Math.min(1, Math.max(0, (p - 0.05) / 0.10));
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = colP;
ctx.fillText(HOURS[j], g.x0 + g.cw * (j + 0.5), g.y0 - 6);
ctx.globalAlpha = 1;
}
// 셀 56개 — 값 계량 → 5단 ramp 색. 채워짐 진행만큼 알파와 크기가 찬다
const hover = hoverRef.current;
let maxV = 0;
let maxCell: { r: number; c: number } | null = null;
let sum = 0, filled = 0;
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const gp = easeOutCubic(cellAt(r, c, p));
if (gp <= 0) continue;
const v = GRID[r][c];
if (gp >= 1) {
filled += 1; sum += v;
if (v > maxV) { maxV = v; maxCell = { r, c }; }
}
const isHover = hover !== null && hover.di === r && hover.hi === c;
const cx = g.x0 + g.cw * c + g.gap / 2 + (g.cw - g.gap) * (1 - gp) / 2; // 중심에서 자라난다
const cy = g.y0 + g.ch * r + g.gap / 2 + (g.ch - g.gap) * (1 - gp) / 2;
const cw = (g.cw - g.gap) * gp;
const ch = (g.ch - g.gap) * gp;
ctx.globalAlpha = 0.30 + 0.70 * gp; // 트랙(0.30)에서 실제 농도로 진해진다
ctx.fillStyle = HEAT[rampAt(v)] || color;
roundRect(ctx, cx, cy, cw, ch, 4);
ctx.fill();
ctx.globalAlpha = 1;
// hover 셀 — 흰 테두리 링
if (isHover && gp >= 1) {
ctx.strokeStyle = C_PAPER;
ctx.lineWidth = 2;
roundRect(ctx, g.x0 + g.cw * c + g.gap / 2, g.y0 + g.ch * r + g.gap / 2, g.cw - g.gap, g.ch - g.gap, 4);
ctx.stroke();
}
}
}
// 평균·최고 라벨 — 전부 찬 뒤(0.8) 흰 무대 위에 뜬다(칩 색 위가 아니라 — 대비)
if (p > 0.8) {
const lp = Math.min(1, (p - 0.8) / 0.10);
ctx.globalAlpha = lp;
ctx.font = "700 10px " + FONT;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillStyle = C_INK;
ctx.fillText("평균 " + (filled ? Math.round(sum / filled) : 0), g.x0, H - 3);
ctx.textAlign = "right";
ctx.fillStyle = C_INK;
if (maxCell) {
ctx.fillText("최고 " + DAYS[maxCell.r] + " " + HOURS[maxCell.c] + "시 " + maxV, W - 10, H - 3);
}
ctx.globalAlpha = 1;
}
// 캔버스 안 툴팁 — hover 셀의 요일·시간대·값. 셀 위에 흰 카드로 그린다(DOM 아님)
if (hover) {
const hv = GRID[hover.di][hover.hi];
const tx = g.x0 + g.cw * (hover.hi + 0.5);
const ty = g.y0 + g.ch * hover.di;
const label = DAYS[hover.di] + " " + HOURS[hover.hi] + "시";
ctx.font = "700 10px " + FONT;
const lw = Math.max(ctx.measureText(label).width, ctx.measureText("값 " + hv).width) + 16;
const bx = Math.min(W - lw - 4, Math.max(4, tx - lw / 2));
const by = Math.max(4, ty - 34);
ctx.fillStyle = C_PAPER;
roundRect(ctx, bx, by, lw, 28, 6);
ctx.fill();
ctx.strokeStyle = C_MUTE;
ctx.lineWidth = 1;
roundRect(ctx, bx, by, lw, 28, 6);
ctx.stroke();
ctx.fillStyle = C_INK;
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
ctx.fillText(label, bx + lw / 2, by + 12);
ctx.fillStyle = HEAT[rampAt(hv)] || color;
ctx.fillRect(bx + 8, by + 17, 8, 8);
ctx.fillStyle = C_INK;
ctx.textAlign = "left";
ctx.fillText("값 " + hv, bx + 20, by + 25);
}
}
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 onMouseMove(e: ReactMouseEvent<HTMLCanvasElement>): void {
const cv = cvRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
hoverRef.current = cellFromPoint(e.clientX - r.left, e.clientY - r.top);
if (reduced()) draw(1);
}
function onMouseLeave(): void {
hoverRef.current = null;
if (reduced()) draw(1);
}
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 — 루프 없이 완성 상태(56셀 전부 찬 히트맵 + 평균·최고 라벨) 한 장만 그린다
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 56v122.06l-39.72-39.72a8 8 0 0 0-11.31 0L147.31 164l-49.65-49.66a8 8 0 0 0-11.32 0L32 168.69V64a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M111 51h-4V32a16 16 0 0 1 32 0v19h-4a102.63 102.63 0 0 1 13 18h23a16 16 0 0 1 16 16v23a102.63 102.63 0 0 1 18 13v4h19a16 16 0 0 1 0 32h-19v4a102.63 102.63 0 0 1-18 13v23a16 16 0 0 1-16 16h-23a102.63 102.63 0 0 1-13 18h4v19a16 16 0 0 1-32 0v-19h4a102.63 102.63 0 0 1-13-18H75a16 16 0 0 1-16-16v-23a102.63 102.63 0 0 1-18-13v-4a102.63 102.63 0 0 1 0-32v-4a102.63 102.63 0 0 1 18-13V85a16 16 0 0 1 16-16h23a102.63 102.63 0 0 1 13-18m-34 34v86h102V85Zm51 75a24 24 0 1 1 24-24a24 24 0 0 1-24 24"/></g></svg>
<span>56칸이 대각선 파도를 그리며 채워집니다 — <code>sin 위상</code>·<code>5단 농도 ramp</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}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
></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>요일×시간대 방문 밀도(0~100) — 시간대 둘씩 묶은 요약</caption>
<thead><tr><th scope="col">요일</th><th scope="col">09~10시</th><th scope="col">11~12시</th><th scope="col">13~14시</th><th scope="col">15~16시</th></tr></thead>
<tbody>
{DAYS.map((d, i) => (
<tr key={d}>
<th scope="row">{d}</th>
<td>{Math.round((GRID[i][0] + GRID[i][1]) / 2)}</td>
<td>{Math.round((GRID[i][2] + GRID[i][3]) / 2)}</td>
<td>{Math.round((GRID[i][4] + GRID[i][5]) / 2)}</td>
<td>{Math.round((GRID[i][6] + GRID[i][7]) / 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}09 히트맵 파동 — 측정 누적면적 34.969% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종
이 데모가 속한 글
canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종canvas 그래프 그리기는 데이터 배열을 x·y 좌표로 바꿔 캔버스 위에 선·막대·원호로 얹는 일입니다. 라이브러리와 CDN 없이 값이 그려지고 차오르고 모여드는 차트 아홉 종을 만들었고, 파일만 열면 복사한 그대 이 글의 데모 전부 보기


