04산포 그래프 등장
40개 상품이 학습 전에는 무작위 자리에서 시작해 반복 학습(lerp)으로 제자리(가격-리뷰수 평면)로 모여든다. 마우스를 올리면 가장 가까운 점이 잡히고 상품명이 뜬다.
쓰임가격-만족도·상관 관계 탐색
lerpMath.expdevicePixelRatio

<main class="stage">
<div class="sa" id="sa">
<p class="sa__hint">
<svg class="sa__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>40개 상품이 흩어진 자리에서 가격-리뷰 평면으로 모여듭니다 — <code>lerp</code> 수렴·<code>LCG</code> 시드·<code>mousemove</code> 하이라이트</span>
</p>
<div class="sa__field" id="field" role="application" aria-label="산포 등장 실험판" tabindex="0">
<canvas class="sa__cv" id="cv" role="img" aria-label="산포 등장 — 40개 상품 점이 흩어진 자리에서 가격-리뷰 평면의 제자리로 모여드는 모습"></canvas>
</div>
<div class="sa__bar">
<button class="sa__btn" id="replay" type="button">
<svg class="sa__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>상품 40개 중 8개 예시 — 가격(만원)·리뷰수</caption>
<thead><tr><th scope="col">상품</th><th scope="col">가격(만원)</th><th scope="col">리뷰수</th></tr></thead>
<tbody id="sampleBody"></tbody>
</table>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 산포 등장 — 움직임은 전부 캔버스 rAF 루프 안(지수 감쇠 lerp 수렴 + 최근접점 하이라이트).
// CSS 애니메이션이 0개여야 렌더러가 실시간 24장을 찍는다(077·304 선례).
// 무대는 $stage-paper(흰) — 차트는 밝은 무대가 정석. 색은 차트 토큰 고정 순서 1번.
$duration: $dur-loop;
$easing: $ease-out; // 지수 감쇠 수렴을 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;
}
.sa {
// 캔버스가 읽는 색 — 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;
}
.sa__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;
}
.sa__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;
}
.sa__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.sa__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);
}
.sa__cv { display: block; width: 100%; height: 100%; }
.sa__bar {
display: flex; align-items: center; gap: $sp-2;
}
.sa__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;
}
.sa__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sa__bico { width: 14px; height: 14px; flex: 0 0 auto; }
@media (max-width: 340px) {
.sa { gap: $sp-1; }
.sa__field { height: 112px; }
.sa__hint { font-size: 10px; }
.sa__btn { padding: $sp-1 $sp-3; font-size: 11px; }
}
// reduce — 루프가 아예 안 돌므로 끄고 말고 할 애니메이션이 없다.
// 완성 상태 한 장이 남으므로 "다시 그리기"는 여전히 정지 이미지를 다시 그린다.
@media (prefers-reduced-motion: reduce) {
.sa__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;
}
.sa {
--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;
}
.sa__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;
}
.sa__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;
}
.sa__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.sa__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);
}
.sa__cv {
display: block;
width: 100%;
height: 100%;
}
.sa__bar {
display: flex;
align-items: center;
gap: 8px;
}
.sa__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;
}
.sa__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sa__bico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
@media (max-width: 340px) {
.sa {
gap: 4px;
}
.sa__field {
height: 112px;
}
.sa__hint {
font-size: 10px;
}
.sa__btn {
padding: 4px 12px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.sa__btn {
cursor: default;
}
}// 산포 등장 — 40개 상품 점이 무작위 시작좌표에서 가격-리뷰 평면의 제자리로 모여드는 수렴 애니메이션.
// 두 겹이 함께 움직인다:
// 1) 각 점은 lerp(반복 학습)의 닫힌형인 지수 감쇠 pos = start + (target-start) * (1 - e^(-lam*t)) 로
// 프레임이 아니라 경과시간의 함수로 위치가 나온다 — 루프가 돌아가도 같은 p 에는 같은 그림.
// 2) lam(수렴 속도)을 점마다 3.0~6.0 으로 흩어 도착 시간이 어긋나 살아 있어 보인다.
// 시작좌표·목표좌표·lam 은 전부 시드 상수 LCG(선형합동법)로 만든다 — Math.random 금지, 재생마다 같은 그림.
var root = document.getElementById('sa');
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';
// 결정적 난수 — minstd 선형합동법. 곱이 2^53 을 넘지 않아 부동소수점 오염도 없다
var SEED = 3050416;
var rs = SEED;
function rnd() {
rs = (rs * 48271) % 2147483647;
return rs / 2147483647;
}
var NAMES = [
'텀블러', '무선 마우스', '기계식 키보드', 'LED 데스크 램프', '노트북 거치대',
'블루투스 이어폰', '캠핑 의자', '스테인리스 보틀', '미니 선풍기', '무선 충전 패드',
'손목 받침대', '아로마 디퓨저', '데스크 매트', '게이밍 헤드셋', '스마트 체중계',
'여행용 파우치', '접이식 우산', '무전등', '멀티탭', '책상용 공기청정기',
'전기 주전자', '커피 그라인더', '원목 코스터', '가죽 키링', '스마트 워치',
'마우스 패드', '웹캠', '마이크 스탠드', '북셸프 스피커', '차량용 홀더',
'러닝 벨트', '요가 매트', '폼롤러', '저항 밴드', '점프 로프',
'캔버스 배낭', '토트백', '선글라스', '미니 블록', '스티커 묶음'
];
// 40개 점 생성 — rnd 호출 순서가 고정이라 목표·시작·속도가 전부 결정적.
// 목표: 가격 0.5~19.5만원, 리뷰수 10~480. 시작: 평면 안 정규화 좌표 0.04~0.96
var PTS = [];
for (var i = 0; i < 40; i++) {
PTS.push({
name: NAMES[i],
price: Math.round((0.5 + rnd() * 19) * 10) / 10,
rv: Math.round(10 + rnd() * 470),
sx: 0.04 + rnd() * 0.92,
sy: 0.04 + rnd() * 0.92,
lam: 3.0 + rnd() * 3.0,
cx: 0, cy: 0
});
}
var PAD_L = 44, PAD_R = 14, PAD_T = 16, PAD_B = 34; // 축 라벨·제목 여백(CSS px)
var LOOP = 2.4; // 한 사이클 2.4초 — 캡처 창(2초) 내내 수렴 진행 중이게 마지막 프레임까지 산다
var DRAW_AT = 0.85; // 전체 2초의 85% 동안 모여들고, 끝 15% 는 도착해 쉰다
var CONV_SEC = 1.6; // 수렴 시간(초) — elapsed 스케일
var X_SPAN = 20; // x 축: 가격 0~20만원
var Y_SPAN = 500; // y 축: 리뷰수 0~500
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1;
var t0 = 0, raf = 0;
var mouse = { x: 0, y: 0, on: false };
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 는 가격(만원), y 는 리뷰수(위로 갈수록 크다)
function px(v) { return PAD_L + (W - PAD_L - PAD_R) * (v / X_SPAN); }
function py(v) { return PAD_T + (H - PAD_T - PAD_B) * (1 - v / Y_SPAN); }
// 닫힌형 lerp 수렴 — 경과시간 e(초)에서 점 위치를 결정적으로 계산해 cx·cy 에 저장, 진행도 t 반환
function converge(pt, e) {
var t = 1 - Math.exp(-pt.lam * e);
var x0 = PAD_L + (W - PAD_L - PAD_R) * pt.sx;
var y0 = PAD_T + (H - PAD_T - PAD_B) * pt.sy;
pt.cx = x0 + (px(pt.price) - x0) * t;
pt.cy = y0 + (py(pt.rv) - y0) * t;
return t;
}
// 모서리가 둥근 상자 경로 — roundRect 를 못 쓰는 환경은 사각형으로 물러난다
function boxPath(x, y, w, h, r) {
if (typeof ctx.roundRect === 'function') { ctx.roundRect(x, y, w, h, r); }
else { ctx.rect(x, y, w, h); }
}
function draw(p) {
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = C_PAPER;
ctx.fillRect(0, 0, W, H);
// 가로 그리드 + y 라벨(리뷰수) — 첫 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 = 0; g <= Y_SPAN; g += 100) {
var gy = py(g) + (1 - axisP) * 14;
ctx.strokeStyle = C_GRID;
ctx.beginPath();
ctx.moveTo(PAD_L, Math.round(gy) + 0.5);
ctx.lineTo(W - PAD_R, Math.round(gy) + 0.5);
ctx.stroke();
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText(String(g), PAD_L - 6, gy);
ctx.globalAlpha = 1;
}
// x 라벨(가격 만원) — 왼쪽부터 차례로 나타난다
ctx.textAlign = 'center';
for (var xv = 0; xv <= X_SPAN; xv += 5) {
var xp = Math.max(0, Math.min(1, (p - 0.10 - (xv / 5) * 0.022) / 0.10));
if (xp <= 0) continue;
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = xp;
ctx.fillText(String(xv), px(xv), H - PAD_B + 11);
ctx.globalAlpha = 1;
}
// 축 제목 — y 는 왼쪽에 세로로, x 는 아래 중앙에
ctx.save();
ctx.translate(12, (PAD_T + H - PAD_B) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText('리뷰수', 0, 0);
ctx.restore();
ctx.globalAlpha = axisP;
ctx.fillStyle = C_MUTE;
ctx.fillText('가격(만원)', (PAD_L + W - PAD_R) / 2, H - 8);
ctx.globalAlpha = 1;
// 수렴 — elapsed = (p / DRAW_AT) * CONV_SEC. 루프가 0 으로 돌아가면 e 도 0 이 되어 점들이 시작좌표로 리셋
var e = CONV_SEC * Math.min(1, p / DRAW_AT);
var hi = -1, hd = Infinity;
for (var k = 0; k < PTS.length; k++) {
var t = converge(PTS[k], e);
// 점 — 도착할수록 진해진다. 종이색 테두리로 겹치는 점을 갈라 놓는다
ctx.globalAlpha = 0.35 + 0.65 * t;
ctx.beginPath();
ctx.arc(PTS[k].cx, PTS[k].cy, 5, 0, Math.PI * 2);
ctx.fillStyle = C_LINE;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = C_PAPER;
ctx.stroke();
ctx.globalAlpha = 1;
// 최근접점 후보 — 마우스가 안 있으면 계산만 비운다
if (mouse.on) {
var dd = (PTS[k].cx - mouse.x) * (PTS[k].cx - mouse.x) + (PTS[k].cy - mouse.y) * (PTS[k].cy - mouse.y);
if (dd < hd) { hd = dd; hi = k; }
}
}
// 최근접점 하이라이트 — 옅은 뒤광 위에 큰 점과 링, 그 위에 상품명 라벨
if (hi >= 0) {
var hp = PTS[hi];
ctx.beginPath();
ctx.arc(hp.cx, hp.cy, 13, 0, Math.PI * 2);
ctx.fillStyle = C_FILL;
ctx.fill();
ctx.beginPath();
ctx.arc(hp.cx, hp.cy, 8.5, 0, Math.PI * 2);
ctx.strokeStyle = C_LINE;
ctx.globalAlpha = 0.4;
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(hp.cx, hp.cy, 5.5, 0, Math.PI * 2);
ctx.fillStyle = C_LINE;
ctx.fill();
// 라벨 상자 — 상품명(굵게) + 가격·리뷰수(옅게). 위에 띄우고 잘리면 아래로
var nm = hp.name;
var info = hp.price.toFixed(1) + '만원 · ' + hp.rv + '리뷰';
ctx.font = '700 11px ' + FONT;
var w1 = ctx.measureText(nm).width;
ctx.font = '600 10px ' + FONT;
var w2 = ctx.measureText(info).width;
var bw = Math.max(w1, w2) + 16, bh = 34;
var lx = Math.max(6, Math.min(W - bw - 6, hp.cx - bw / 2));
var ly = hp.cy - 16 - bh;
if (ly < 6) ly = hp.cy + 16;
ctx.beginPath();
boxPath(lx, ly, bw, bh, 5);
ctx.fillStyle = C_PAPER;
ctx.fill();
ctx.strokeStyle = C_GRID;
ctx.lineWidth = 1;
ctx.stroke();
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.font = '700 11px ' + FONT;
ctx.fillStyle = C_INK;
ctx.fillText(nm, lx + 8, ly + 14);
ctx.font = '600 10px ' + FONT;
ctx.fillStyle = C_MUTE;
ctx.fillText(info, lx + 8, ly + 26);
}
}
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; });
field.addEventListener('mousemove', function (ev) {
var r = cv.getBoundingClientRect();
mouse.x = ev.clientX - r.left;
mouse.y = ev.clientY - r.top;
mouse.on = true;
if (reduce) draw(1); // 정지 화면에서는 이벤트에 그려 응답한다
});
field.addEventListener('mouseleave', function () {
mouse.on = false;
if (reduce) draw(1);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1); });
// 화면 낭독기용 표 — 40행은 너무 기니 대표 8개만. 값은 위 LCG 계산과 같은 배열에서 가져온다
var tb = document.getElementById('sampleBody');
for (var s = 0; s < 8; s++) {
var tr = document.createElement('tr');
var th = document.createElement('th');
th.setAttribute('scope', 'row');
th.textContent = PTS[s].name;
var td1 = document.createElement('td');
td1.textContent = PTS[s].price.toFixed(1);
var td2 = document.createElement('td');
td2.textContent = String(PTS[s].rv);
tr.appendChild(th); tr.appendChild(td1); tr.appendChild(td2);
tb.appendChild(tr);
}
fit();
if (reduce) {
// reduce — 루프 없이 다 모여든 완성 상태(40개 전부 제자리) 한 장만 그린다
draw(1);
} else {
raf = requestAnimationFrame(frame);
}// ScatterArrivalChart — vanilla 판(run/305/demo/04-chart-canvas-scatter-arrival/index.html) 의 React 포트.
// 40개 상품 점이 흩어진 시작좌표에서 지수 감쇠 lerp 로 가격-리뷰 평면의 제자리로 모여들고,
// mousemove 최근접점이 커지며 상품명 라벨이 뜬다. 시작·목표 좌표는 시드 상수 LCG 로 결정적으로 만든다.
// 움직임은 캔버스 rAF 루프가 전부 그리므로 @keyframes 가 없다 — props 는 CSS 변수로 내려간다.
import { useEffect, useRef } from "react";
import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
import styles from "./ScatterArrivalChart.module.scss";
export type ScatterArrivalChartProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type Pt = {
name: string; price: number; rv: number;
sx: number; sy: number; lam: number; cx: number; cy: number;
};
const NAMES = [
"텀블러", "무선 마우스", "기계식 키보드", "LED 데스크 램프", "노트북 거치대",
"블루투스 이어폰", "캠핑 의자", "스테인리스 보틀", "미니 선풍기", "무선 충전 패드",
"손목 받침대", "아로마 디퓨저", "데스크 매트", "게이밍 헤드셋", "스마트 체중계",
"여행용 파우치", "접이식 우산", "무전등", "멀티탭", "책상용 공기청정기",
"전기 주전자", "커피 그라인더", "원목 코스터", "가죽 키링", "스마트 워치",
"마우스 패드", "웹캠", "마이크 스탠드", "북셸프 스피커", "차량용 홀더",
"러닝 벨트", "요가 매트", "폼롤러", "저항 밴드", "점프 로프",
"캔버스 배낭", "토트백", "선글라스", "미니 블록", "스티커 묶음"
];
const PAD_L = 44, PAD_R = 14, PAD_T = 16, PAD_B = 34;
const LOOP = 2; // 한 사이클 2초
const DRAW_AT = 0.85; // 전체의 85% 동안 모여들고 끝 15% 는 쉰다
const CONV_SEC = 1.6; // 수렴 시간(초) — elapsed 스케일
const X_SPAN = 20; // x 축: 가격 0~20만원
const Y_SPAN = 500; // y 축: 리뷰수 0~500
const SEED = 3050416; // LCG 시드 — 재생마다 같은 그림
const FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
const FIELD_LABEL = "산포 등장 실험판";
const CV_LABEL = "산포 등장 — 40개 상품 점이 흩어진 자리에서 가격-리뷰 평면의 제자리로 모여드는 모습";
// 40개 점 생성 — rnd 호출 순서가 고정이라 목표·시작·속도가 전부 결정적
function buildPts(): Pt[] {
const pts: Pt[] = [];
let rs = SEED;
const rnd = (): number => {
rs = (rs * 48271) % 2147483647; // minstd 선형합동법 — 곱이 2^53 을 안 넘는다
return rs / 2147483647;
};
for (let i = 0; i < 40; i++) {
pts.push({
name: NAMES[i],
price: Math.round((0.5 + rnd() * 19) * 10) / 10,
rv: Math.round(10 + rnd() * 470),
sx: 0.04 + rnd() * 0.92,
sy: 0.04 + rnd() * 0.92,
lam: 3.0 + rnd() * 3.0,
cx: 0, cy: 0,
});
}
return pts;
}
export default function ScatterArrivalChart({
duration = "2s", // vanilla $duration: $dur-loop
easing = "ease-out", // vanilla $easing: $ease-out
color = "#2f6df6", // vanilla $color: $chart-1
}: ScatterArrivalChartProps) {
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 ptsRef = useRef<Pt[]>(buildPts()) as Box<Pt[]>;
const mouseRef = useRef({ x: 0, y: 0, on: false }) as Box<{ x: number; y: number; on: boolean }>;
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 는 가격(만원), y 는 리뷰수(위로 갈수록 크다)
function px(v: number): number {
const W = sizeRef.current.w;
return PAD_L + (W - PAD_L - PAD_R) * (v / X_SPAN);
}
function py(v: number): number {
const H = sizeRef.current.h;
return PAD_T + (H - PAD_T - PAD_B) * (1 - v / Y_SPAN);
}
// 닫힌형 lerp 수렴 — 경과시간 e(초)에서 점 위치를 결정적으로 계산해 cx·cy 에 저장
function converge(pt: Pt, e: number): number {
const W = sizeRef.current.w, H = sizeRef.current.h;
const t = 1 - Math.exp(-pt.lam * e);
const x0 = PAD_L + (W - PAD_L - PAD_R) * pt.sx;
const y0 = PAD_T + (H - PAD_T - PAD_B) * pt.sy;
pt.cx = x0 + (px(pt.price) - x0) * t;
pt.cy = y0 + (py(pt.rv) - y0) * t;
return t;
}
// 모서리가 둥근 상자 경로 — roundRect 를 못 쓰는 환경은 사각형으로 물러난다
function boxPath(x: number, y: number, w: number, h: number, r: number): void {
const ctx = ctxRef.current!;
if (typeof ctx.roundRect === "function") { ctx.roundRect(x, y, w, h, r); }
else { ctx.rect(x, y, w, h); }
}
function draw(p: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const W = sizeRef.current.w, H = sizeRef.current.h;
const PTS = ptsRef.current;
const mouse = mouseRef.current;
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 = 0; g <= Y_SPAN; g += 100) {
const gy = py(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 xv = 0; xv <= X_SPAN; xv += 5) {
const xp = Math.max(0, Math.min(1, (p - 0.10 - (xv / 5) * 0.022) / 0.10));
if (xp <= 0) continue;
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = xp;
ctx.fillText(String(xv), px(xv), H - PAD_B + 11);
ctx.globalAlpha = 1;
}
// 축 제목 — y 는 왼쪽에 세로로, x 는 아래 중앙에
ctx.save();
ctx.translate(12, (PAD_T + H - PAD_B) / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillStyle = C_MUTE;
ctx.globalAlpha = axisP;
ctx.fillText("리뷰수", 0, 0);
ctx.restore();
ctx.globalAlpha = axisP;
ctx.fillStyle = C_MUTE;
ctx.fillText("가격(만원)", (PAD_L + W - PAD_R) / 2, H - 8);
ctx.globalAlpha = 1;
// 수렴 — elapsed = (p / DRAW_AT) * CONV_SEC. 루프가 0 으로 돌아가면 점들이 시작좌표로 리셋
const e = CONV_SEC * Math.min(1, p / DRAW_AT);
let hi = -1, hd = Infinity;
for (let k = 0; k < PTS.length; k++) {
const t = converge(PTS[k], e);
// 점 — 도착할수록 진해진다. 종이색 테두리로 겹치는 점을 갈라 놓는다
ctx.globalAlpha = 0.35 + 0.65 * t;
ctx.beginPath();
ctx.arc(PTS[k].cx, PTS[k].cy, 3, 0, Math.PI * 2);
ctx.fillStyle = C_LINE;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = C_PAPER;
ctx.stroke();
ctx.globalAlpha = 1;
// 최근접점 후보 — 마우스가 안 있으면 계산만 비운다
if (mouse.on) {
const dd = (PTS[k].cx - mouse.x) * (PTS[k].cx - mouse.x) + (PTS[k].cy - mouse.y) * (PTS[k].cy - mouse.y);
if (dd < hd) { hd = dd; hi = k; }
}
}
// 최근접점 하이라이트 — 옅은 뒤광 위에 큰 점과 링, 그 위에 상품명 라벨
if (hi >= 0) {
const hp = PTS[hi];
ctx.beginPath();
ctx.arc(hp.cx, hp.cy, 13, 0, Math.PI * 2);
ctx.fillStyle = C_FILL;
ctx.fill();
ctx.beginPath();
ctx.arc(hp.cx, hp.cy, 8.5, 0, Math.PI * 2);
ctx.strokeStyle = C_LINE;
ctx.globalAlpha = 0.4;
ctx.lineWidth = 2;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(hp.cx, hp.cy, 5.5, 0, Math.PI * 2);
ctx.fillStyle = C_LINE;
ctx.fill();
// 라벨 상자 — 상품명(굵게) + 가격·리뷰수(옅게). 위에 띄우고 잘리면 아래로
const nm = hp.name;
const info = hp.price.toFixed(1) + "만원 · " + hp.rv + "리뷰";
ctx.font = "700 11px " + FONT;
const w1 = ctx.measureText(nm).width;
ctx.font = "600 10px " + FONT;
const w2 = ctx.measureText(info).width;
const bw = Math.max(w1, w2) + 16, bh = 34;
const lx = Math.max(6, Math.min(W - bw - 6, hp.cx - bw / 2));
let ly = hp.cy - 16 - bh;
if (ly < 6) ly = hp.cy + 16;
ctx.beginPath();
boxPath(lx, ly, bw, bh, 5);
ctx.fillStyle = C_PAPER;
ctx.fill();
ctx.strokeStyle = C_GRID;
ctx.lineWidth = 1;
ctx.stroke();
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.font = "700 11px " + FONT;
ctx.fillStyle = C_INK;
ctx.fillText(nm, lx + 8, ly + 14);
ctx.font = "600 10px " + FONT;
ctx.fillStyle = C_MUTE;
ctx.fillText(info, lx + 8, ly + 26);
}
}
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 onMove(ev: ReactMouseEvent<HTMLDivElement>): void {
const cv = cvRef.current;
const mouse = mouseRef.current;
if (!cv) return;
const r = cv.getBoundingClientRect();
mouse.x = ev.clientX - r.left;
mouse.y = ev.clientY - r.top;
mouse.on = true;
if (reduced()) draw(1); // 정지 화면에서는 이벤트에 그려 응답한다
}
function onLeave(): void {
mouseRef.current.on = false;
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 — 루프 없이 다 모여든 완성 상태 한 장만 그린다
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>40개 상품이 흩어진 자리에서 가격-리뷰 평면으로 모여듭니다 — <code>lerp</code> 수렴·<code>LCG</code> 시드·<code>mousemove</code> 하이라이트</span>
</p>
<div
className={styles.field}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onMouseMove={onMove}
onMouseLeave={onLeave}
>
<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>상품 40개 중 8개 예시 — 가격(만원)·리뷰수</caption>
<thead><tr><th scope="col">상품</th><th scope="col">가격(만원)</th><th scope="col">리뷰수</th></tr></thead>
<tbody>
{ptsRef.current.slice(0, 8).map((pt, i) => (
<tr key={i}><th scope="row">{pt.name}</th><td>{pt.price.toFixed(1)}</td><td>{pt.rv}</td></tr>
))}
</tbody>
</table>
</div>
</div>
</main>
);
}04 산포 그래프 등장 — 측정 누적면적 19.836% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종
이 데모가 속한 글
canvas 그래프 그리기 — 라이브러리 없이 그리는 애니메이션 차트 9종canvas 그래프 그리기는 데이터 배열을 x·y 좌표로 바꿔 캔버스 위에 선·막대·원호로 얹는 일입니다. 라이브러리와 CDN 없이 값이 그려지고 차오르고 모여드는 차트 아홉 종을 만들었고, 파일만 열면 복사한 그대 이 글의 데모 전부 보기


