04Scatter arrival
Forty products start at random positions and settle onto the price-review plane through repeated lerps; hovering catches the nearest point and names it.
Best forPrice-satisfaction and correlation exploration
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>Forty products converge from scattered spots onto the price-review plane — <code>lerp</code> convergence ·<code>LCG</code> seed ·<code>mousemove</code> highlight</span>
</p>
<div class="sa__field" id="field" role="application" aria-label="Scatter arrival test board" tabindex="0">
<canvas class="sa__cv" id="cv" role="img" aria-label="Scatter arrival — forty product dots converging from scattered spots to their price-review places"></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>Redraw</span>
</button>
<table class="sr-only">
<caption>8 of 40 products as a sample — price (10K KRW) · reviews</caption>
<thead><tr><th scope="col">Product</th><th scope="col">Price (10K KRW)</th><th scope="col">Reviews</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개 Product 점이 무작위 시작좌표에서 가격- reviews 평면의 제자리로 모여드는 수렴 애니메이션.
// 두 겹이 함께 움직인다:
// 1) 각 점은 lerp(반복 학습)의 닫힌형인 지수 감쇠 pos = start + (target-start) * (1 - e^(-lam*t)) 로
// 프레임이 아니라 경과시간의 함수로 위치가 나온다 — 루프가 돌아가도 같은 p 에는 같은 그림.
// 2) lam(수렴 Speed)을 점마다 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 = [
'Tumbler', 'Wireless mouse', 'Mechanical keyboard', 'LED desk lamp', 'Laptop stand',
'Bluetooth earbuds', 'Camping chair', 'Stainless bottle', 'Mini fan', 'Wireless charging pad',
'Wrist rest', 'Aroma diffuser', 'Desk mat', 'Gaming headset', 'Smart scale',
'Travel pouch', 'Folding umbrella', 'Flashlight', 'Power strip', 'Desk air purifier',
'Electric kettle', 'Coffee grinder', 'Wooden coaster', 'Leather keyring', 'Smartwatch',
'Mouse pad', 'Webcam', 'Mic stand', 'Bookshelf speaker', 'Car mount',
'Running belt', 'Yoga mat', 'Foam roller', 'Resistance band', 'Jump rope',
'Canvas backpack', 'Tote bag', 'Sunglasses', 'Mini blocks', 'Sticker pack'
];
// 40개 점 생성 — rnd 호출 순서가 고정이라 목표·시작·속도가 전부 결정적.
// 목표: 가격 0.5~19.5만원, Reviews 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; // Axis 라벨·제목 여백(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 Axis: 가격 0~20만원
var Y_SPAN = 500; // y Axis: Reviews 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);
}
// 데이터 Value → 플롯 좌표. x 는 Price (10K KRW), y 는 Reviews(위로 갈수록 크다)
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 라벨(Reviews) — 첫 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;
}
// Axis 제목 — 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('Reviews', 0, 0);
ctx.restore();
ctx.globalAlpha = axisP;
ctx.fillStyle = C_MUTE;
ctx.fillText('Price (10K KRW)', (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; }
}
}
// 최근접점 highlight — 옅은 뒤광 위에 큰 점과 링, 그 위에 상품명 라벨
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();
// 라벨 상자 — 상품명(굵게) + 가격·Reviews(옅게). 위에 띄우고 잘리면 아래로
var nm = hp.name;
var info = hp.price.toFixed(1) + '×10K KRW · ' + hp.rv + ' reviews';
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 Scatter arrival — Measured area 19.836% · moving frames 21/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


