눈 내리는 효과 9종 — 소스 그대로 복붙하는 눈송이부터 쌓이는 눈까지
눈 내리는 효과는 캔버스에 눈송이 입자를 그리고 깊이·바람·쌓임을 계산으로 움직이는 겨울 장면이다. 눈송이는 왜 큰 것이 더 빠르고 선명할까? 01번은 크기를 깊이로 읽어 속도와 흐림을 함께 정하고, 03번은 쌓인 눈이 무너지는 기울기를 지키며, 06번은 가로등 빛 원뿔 안에서만 눈을
눈은 떨어지는 점이 아니라 깊이를 가진 입자다 — 눈 내리는 효과는 크기 하나로 속도·흐림·흔들림을 정하는 데서 시작한다
01 snw-depth-parallax-flakes
02 snw-gust-field-swirl
03 snw-pile-repose-heightmap
04 snw-window-melt-trail
05 snw-globe-shake-settle
06 snw-lamp-cone-glow
07 snw-umbrella-deflect-slide
08 snw-blizzard-streak-whiteout
09 snw-hero-roof-night
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 크기로 깊이를 읽는 눈송이 층
- 02 돌풍 장이 휘감는 눈
- 03 쌓이며 무너지는 눈더미
- 04 창유리에 닿아 녹는 눈
- 05 흔들었다 가라앉는 눈 유리구슬
- 06 가로등 빛 원뿔 속 눈
- 07 우산 위를 미끄러지는 눈
- 08 줄무늬로 늘어나는 눈보라
- 09 눈 내리는 밤 지붕 히어로 장면
아홉 장면은 같은 눈 입자를 쓰고 그 값을 읽는 방식만 다르다. 01·02 는 눈이 어떻게 떨어지는지, 03·05·07 은 닿은 눈이 어디에 어떻게 남는지, 04·06 은 눈과 빛이 만드는 질감을 다룬다. 08 은 속도를 줄무늬로 바꿔 시야를 닫고, 09 는 앞의 계산을 한 화면에 겹친다.
큰 눈이 빠르고 또렷한 이유
01 은 눈송이 반지름 크기 s 를 S_MIN 1.1 과 S_MAX 5.2 사이에서 0~1 의 깊이 z 로 바꾼다. 그 z 하나가 낙하 속도(0.6~1.4배), 번짐 반지름(최대 3.4px), 가로 흔들림 폭(4.5~19.5px)을 한꺼번에 정하므로 가장 큰 눈은 가장 작은 눈보다 약 2.3배 빨리 떨어진다. 눈마다 값을 따로 고르지 않아도 앞뒤 거리감이 생기는 것이 이 장면의 요점이다.
눈 내리는 효과 소스는 캔버스 2D 하나로 끝난다
아홉 장면은 외부 라이브러리·WebGL·CDN 없이 캔버스 2D 와 requestAnimationFrame 만 쓴다. 난수는 시드를 고정한 생성기라 같은 코드를 같은 값으로 돌리면 같은 눈이 내리고, 한 프레임 시간은 DT_MAX 0.05초로 잘라 쓴다. 파일 한 개를 복사해 붙이면 바로 돌아가므로 눈 내리는 효과 소스를 찾는 사람이 설정 없이 확인하기 좋다.
이웃한 편과 갈리는 지점
점이 떠다니는 장식은 css 파티클 배경 편이 다루고, 비가 땅에 튕기는 장면은 비 효과 편이 다룬다. 이 편은 눈이 닿은 자리에 남는다는 점이 다르다 — 높이 지도에 쌓이고, 유리를 적시고, 우산 위를 미끄러진다. 날씨를 아이콘으로 보여 주는 카드는 날씨 위젯 ui 편, 같은 캔버스 장면 기준선은 캔버스 특수효과 편에 있다.
눈 내리는 효과 9종 — 크기로 깊이를 읽는 눈송이 층·돌풍 장이 휘감는 눈·쌓이며 무너지는 눈더미·창유리에 닿아 녹는 눈·흔들었다 가라앉는 눈 유리구슬·가로등 빛 원뿔 속 눈·우산 위를 미끄러지는 눈·줄무늬로 늘어나는 눈보라·눈 내리는 밤 지붕 히어로 장면
01·02 는 낙하의 계산을 바꾸고 03~05 와 07 은 닿은 뒤의 상태를 저장한다. 06 은 밝기만 바꾸고 눈의 움직임은 그대로 둔다. 08 은 같은 눈을 속도 방향으로 늘이며, 09 는 03 의 쌓임과 06 의 불빛과 01 의 깊이를 함께 올린다.
아홉 장면을 렌더해서 얻은 면적·프레임·강도
| 장면 | 누적면적 | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 깊이 층 눈송이 | 46.6% | 23/23 | 48.3 |
| 02 돌풍 장 | 41.8% | 23/23 | 59.7 |
| 03 쌓이는 눈더미 | 27.2% | 23/23 | 85.1 |
| 04 창유리 녹는 눈 | 21.6% | 23/23 | 29.4 |
| 05 눈 유리구슬 | 20.5% | 23/23 | 81.0 |
| 06 가로등 원뿔 | 48.4% | 23/23 | 42.0 |
| 07 우산 미끄러짐 | 24.0% | 23/23 | 57.4 |
| 08 눈보라 | 70.2% | 23/23 | 46.8 |
| 09 지붕 히어로 | 27.7% | 23/23 | 46.2 |
화면 전체에 줄무늬와 흰 막이 깔리는 08 이 70.2% 로 가장 넓고, 구슬 안에서만 눈이 움직이는 05 가 20.5% 로 가장 좁다. 강도 평균은 두 패널에 눈더미가 솟는 03 이 85.1 로 가장 높고 유리 위 물기만 바뀌는 04 가 29.4 로 가장 낮다. 아홉 장면 모두 23/23 프레임에서 움직였다.
01크기로 깊이를 읽는 눈송이 층
겨울 이벤트 랜딩 첫 화면 배경에 깔면 눈 240알이 앞뒤 층으로 나뉘어 떨어지는 모습을 볼 수 있다. speedK 는 깊이 0~1 을 속도 배율 0.6~1.4 로 펴고, blurOf 는 먼 눈일수록 번짐을 키운다. 눈을 크기 순으로 한 번 정렬해 두어 가까운 눈이 위에 그려진다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="눈송이 크기 하나로 낙하 속도와 흐림과 흔들림이 정해져, 큰 눈은 빠르고 또렷하게 앞에서 흔들리고 작은 눈은 느리고 흐릿하게 멀리서 가라앉는 눈 내리는 저녁 풍경"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 01 크기로 깊이를 읽는 눈송이 층 ───────────────────────────
// 깊이 간격 — 0 이면 모든 눈이 같은 빠르기·같은 선명도. 값이 클수록 가까운 눈과 먼 눈의 차가 커진다
const Z_SPREAD = 0.5;
// 기준 낙하 빠르기(px/초)
const FALL = 66;
// 가장 먼 눈의 번짐 반지름 바탕값(px)
const BLUR = 3.4;
// 가로 흔들림 진폭 바탕값(px)
const SWAY = 15;
const FLAKES = 240;
const S_MIN = 1.1;
const S_MAX = 5.2;
const LOOP_S = 6;
const REST_STEPS = 60;
let flakes = [];
let rng = seeded(101);
// 크기에서 깊이 — 가장 작은 눈 0, 가장 큰 눈 1
function depthOf(s) { return (s - S_MIN) / (S_MAX - S_MIN); }
// 깊이에서 속도 배율 — Z_SPREAD 가 0.5 면 0.6 ~ 1.4 배, 1.0 이면 0.2 ~ 1.8 배
function speedK(z) { return lerp(1 - 0.8 * Z_SPREAD, 1 + 0.8 * Z_SPREAD, z); }
function blurOf(z) { return (1 - z) * BLUR * (0.5 + Z_SPREAD); }
function fallOf(f) { return FALL * speedK(f.z); }
function reset() {
rng = seeded(101);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = S_MIN + Math.pow(rng(), 1.7) * (S_MAX - S_MIN);
flakes.push({ bx: rng() * (W + 60) - 30, y: rng() * (H + 20) - 10, s: s, z: depthOf(s), ph: rng() * TAU });
}
// 먼 것부터 그린다 — 가까운 눈이 위에 놓이게 크기 순으로 한 번만 정렬
flakes.sort(function (a, b) { return a.s - b.s; });
}
function update(dt) {
// 자동 시연 바람 — 천천히 오가는 사인. 사람이 만지면 그 값으로 바뀐다
const wind = autoOn ? Math.sin(sceneT * 1.1) * 0.6 : userWind();
for (const f of flakes) {
f.y += fallOf(f) * dt;
f.bx += wind * 34 * (0.3 + f.z) * dt;
if (f.y > H + 8) {
f.y -= H + 16;
}
if (f.bx > W + 30) {
f.bx -= W + 60;
}
if (f.bx < -30) {
f.bx += W + 60;
}
}
}
function draw() {
skyFill(SKY_TOP, SKY_BOT);
// 먼 언덕 둘 — 깊이 층이 겹쳐 보이는 바탕
for (let layer = 0; layer < 2; layer += 1) {
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 8) {
const y = 238 + layer * 18 + Math.sin(x * (0.013 + layer * 0.01) + layer * 2) * (12 - layer * 3);
ctx.lineTo(x, y);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = rgba(mix(DUSK, GROUND, layer * 0.55), 1);
ctx.fill();
}
for (const f of flakes) {
const sway = Math.sin(sceneT * 1.3 + f.ph) * SWAY * (0.3 + f.z);
const tint = mix(SNOW, SKY_BOT, (1 - f.z) * 0.35);
flakeDot(f.bx + sway, f.y, f.s * 0.62, blurOf(f.z), tint, lerp(0.5, 1, f.z));
}
}
function statusText() {
let fast = 0;
let slow = 1e9;
for (const f of flakes) {
const v = fallOf(f);
if (v > fast) {
fast = v;
}
if (v < slow) {
slow = v;
}
}
return '눈송이 {n}개 · 가장 큰 눈 {a}px/초 · 가장 작은 눈 {b}px/초'.replace('{n}', String(flakes.length)).replace('{a}', String(Math.round(fast))).replace('{b}', String(Math.round(slow)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DepthParallaxFlakes - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DepthParallaxFlakes.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DepthParallaxFlakes() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 01 크기로 깊이를 읽는 눈송이 층 ───────────────────────────
// 깊이 간격 — 0 이면 모든 눈이 같은 빠르기·같은 선명도. 값이 클수록 가까운 눈과 먼 눈의 차가 커진다
const Z_SPREAD = 0.5;
// 기준 낙하 빠르기(px/초)
const FALL = 66;
// 가장 먼 눈의 번짐 반지름 바탕값(px)
const BLUR = 3.4;
// 가로 흔들림 진폭 바탕값(px)
const SWAY = 15;
const FLAKES = 240;
const S_MIN = 1.1;
const S_MAX = 5.2;
const LOOP_S = 6;
const REST_STEPS = 60;
type Flake = { bx: number; y: number; s: number; z: number; ph: number };
let flakes: Flake[] = [];
let rng = seeded(101);
// 크기에서 깊이 — 가장 작은 눈 0, 가장 큰 눈 1
function depthOf(s: number): number { return (s - S_MIN) / (S_MAX - S_MIN); }
// 깊이에서 속도 배율 — Z_SPREAD 가 0.5 면 0.6 ~ 1.4 배, 1.0 이면 0.2 ~ 1.8 배
function speedK(z: number): number { return lerp(1 - 0.8 * Z_SPREAD, 1 + 0.8 * Z_SPREAD, z); }
function blurOf(z: number): number { return (1 - z) * BLUR * (0.5 + Z_SPREAD); }
function fallOf(f: Flake): number { return FALL * speedK(f.z); }
function reset(): void {
rng = seeded(101);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = S_MIN + Math.pow(rng(), 1.7) * (S_MAX - S_MIN);
flakes.push({ bx: rng() * (W + 60) - 30, y: rng() * (H + 20) - 10, s: s, z: depthOf(s), ph: rng() * TAU });
}
// 먼 것부터 그린다 — 가까운 눈이 위에 놓이게 크기 순으로 한 번만 정렬
flakes.sort(function (a: Flake, b: Flake): number { return a.s - b.s; });
}
function update(dt: number): void {
// 자동 시연 바람 — 천천히 오가는 사인. 사람이 만지면 그 값으로 바뀐다
const wind = autoOn ? Math.sin(sceneT * 1.1) * 0.6 : userWind();
for (const f of flakes) {
f.y += fallOf(f) * dt;
f.bx += wind * 34 * (0.3 + f.z) * dt;
if (f.y > H + 8) { f.y -= H + 16; }
if (f.bx > W + 30) { f.bx -= W + 60; }
if (f.bx < -30) { f.bx += W + 60; }
}
}
function draw(): void {
skyFill(SKY_TOP, SKY_BOT);
// 먼 언덕 둘 — 깊이 층이 겹쳐 보이는 바탕
for (let layer = 0; layer < 2; layer += 1) {
ctx.beginPath();
ctx.moveTo(0, H);
for (let x = 0; x <= W; x += 8) {
const y = 238 + layer * 18 + Math.sin(x * (0.013 + layer * 0.01) + layer * 2) * (12 - layer * 3);
ctx.lineTo(x, y);
}
ctx.lineTo(W, H);
ctx.closePath();
ctx.fillStyle = rgba(mix(DUSK, GROUND, layer * 0.55), 1);
ctx.fill();
}
for (const f of flakes) {
const sway = Math.sin(sceneT * 1.3 + f.ph) * SWAY * (0.3 + f.z);
const tint = mix(SNOW, SKY_BOT, (1 - f.z) * 0.35);
flakeDot(f.bx + sway, f.y, f.s * 0.62, blurOf(f.z), tint, lerp(0.5, 1, f.z));
}
}
function statusText(): string {
let fast = 0;
let slow = 1e9;
for (const f of flakes) {
const v = fallOf(f);
if (v > fast) { fast = v; }
if (v < slow) { slow = v; }
}
return '눈송이 {n}개 · 가장 큰 눈 {a}px/초 · 가장 작은 눈 {b}px/초'.replace('{n}', String(flakes.length)).replace('{a}', String(Math.round(fast))).replace('{b}', String(Math.round(slow)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'눈송이 크기 하나로 낙하 속도와 흐림과 흔들림이 정해져, 큰 눈은 빠르고 또렷하게 앞에서 흔들리고 작은 눈은 느리고 흐릿하게 멀리서 가라앉는 눈 내리는 저녁 풍경'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 크기로 깊이를 읽는 눈송이 층 — 측정 누적면적 46.611% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function depthOf(s) { return (s - S_MIN) / (S_MAX - S_MIN); }
// 깊이에서 속도 배율 — Z_SPREAD 가 0.5 면 0.6 ~ 1.4 배, 1.0 이면 0.2 ~ 1.8 배
function speedK(z) { return lerp(1 - 0.8 * Z_SPREAD, 1 + 0.8 * Z_SPREAD, z); }
function blurOf(z) { return (1 - z) * BLUR * (0.5 + Z_SPREAD); }
function fallOf(f) { return FALL * speedK(f.z); }
function reset() {
rng = seeded(101);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = S_MIN + Math.pow(rng(), 1.7) * (S_MAX - S_MIN);
flakes.push({ bx: rng() * (W + 60) - 30, y: rng() * (H + 20) - 10, s: s, z: depthOf(s), ph: rng() * TAU });
}
// 먼 것부터 그린다 — 가까운 눈이 위에 놓이게 크기 순으로 한 번만 정렬
flakes.sort(function (a, b) { return a.s - b.s; });
}
02돌풍 장이 휘감는 눈
계절 한정 이벤트 팝업 뒤에 두면 눈이 한 줄로 떨어지다 돌풍 자리에서 휘감겨 올라갔다 가라앉는다. 사인 셋을 합친 값의 기울기를 90도 돌려 속도로 쓰기 때문에 눈이 한 점으로 몰리지도 퍼지기만 하지도 않고 소용돌이로 돈다. 깊이가 얕은 눈은 장 값에 0.3~1.0 몫만 받아 덜 휘감긴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="시간에 따라 움직이는 돌풍 장이 눈의 위치마다 회전하는 속도를 더해 눈이 한 줄로 떨어지다 휘감겨 올라갔다 가라앉고, 격자의 짧은 선이 장의 방향을 보여 준다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 02 돌풍 장이 휘감는 눈 ────────────────────────────────────
// 돌풍 세기 — 장이 눈에 더하는 속도 배율
const GUST = 0.6;
// 돌풍 세기 1 · 봉우리 1 일 때 장 값 1 당 눈에 더하는 빠르기(px/초)
const GUST_SPEED = 200;
const FLAKES = 230;
const LOOP_S = 6;
const REST_STEPS = 60;
// 장의 사인 셋 — [가로 파수, 세로 파수, 시간 빠르기, 위상, 진폭]
const WAVES = [
[0.021, 0.015, 0.9, 0.4, 1],
[-0.013, 0.027, -0.7, 2.1, 0.8],
[0.033, -0.011, 1.3, 4.0, 0.5]
];
let flakes = [];
let rng = seeded(202);
// 장의 속도 — 흐름 함수 ψ = Σ a·sin(kx·x + ky·y + ω·t + φ) 의 회전(∂ψ/∂y, −∂ψ/∂x)이라 한 점으로 모이거나 퍼지지 않고 소용돌이로 돈다
function fieldAt(x, y, t) {
let u = 0;
let v = 0;
for (const w of WAVES) {
const c = Math.cos(x * w[0] + y * w[1] + t * w[2] + w[3]) * w[4];
u += c * w[1];
v -= c * w[0];
}
return [u * 34, v * 34];
}
// 돌풍 봉우리 — 시간에 따라 세졌다 가라앉는 몫(0.3 ~ 1)
function gustAmp(t) { return 0.65 + 0.35 * Math.sin(t * 1.4); }
function dropFlake(f, top) {
f.x = rng() * (W + 40) - 20;
f.y = top ? -6 : rng() * H;
f.px = f.x;
f.py = f.y;
}
function reset() {
rng = seeded(202);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = 1.1 + Math.pow(rng(), 1.7) * 4.1;
const f = { x: 0, y: 0, px: 0, py: 0, z: (s - 1.1) / 4.1, s: s };
dropFlake(f, false);
flakes.push(f);
}
flakes.sort(function (a, b) { return a.s - b.s; });
}
function update(dt) {
const amp = GUST * GUST_SPEED * gustAmp(sceneT);
const user = userWind() * 70;
for (const f of flakes) {
f.px = f.x;
f.py = f.y;
// 반 걸음 적분 — 반 걸음 앞에서 장을 읽어 그 속도로 한 걸음 간다
const fall = 30 + 52 * f.z;
const depthK = 0.3 + 0.7 * f.z;
const a = fieldAt(f.x, f.y, sceneT);
const mx = f.x + (a[0] * amp * depthK + user) * dt * 0.5;
const my = f.y + (fall + a[1] * amp * depthK) * dt * 0.5;
const b = fieldAt(mx, my, sceneT);
f.x += (b[0] * amp * depthK + user) * dt;
f.y += (fall + b[1] * amp * depthK) * dt;
if (f.y > H + 8 || f.x > W + 30 || f.x < -30) {
dropFlake(f, true);
}
if (f.y < -40) {
f.y = -6;
f.px = f.x;
f.py = f.y;
}
}
}
function draw() {
skyFill(SKY_TOP, SKY_BOT);
// 장을 눈에 보이게 — 격자 한 칸마다 그 자리의 속도 방향 짧은 선
const amp = GUST * gustAmp(sceneT);
for (let gy = 20; gy < H; gy += 40) {
for (let gx = 20; gx < W; gx += 40) {
const a = fieldAt(gx, gy, sceneT);
const m = Math.hypot(a[0], a[1]);
if (m < 0.01) {
continue;
}
const len = Math.min(16, m * amp * 18);
seg(gx - a[0] / m * len * 0.5, gy - a[1] / m * len * 0.5, gx + a[0] / m * len * 0.5, gy + a[1] / m * len * 0.5, 2, SNOW_SHADE, 0.28);
}
}
for (const f of flakes) {
const sx = f.x - f.px;
const sy = f.y - f.py;
seg(f.px - sx * 2.2, f.py - sy * 2.2, f.x, f.y, f.s * 0.5, SNOW, 0.2 + 0.25 * f.z);
flakeDot(f.x, f.y, f.s * 0.62, (1 - f.z) * 1.6, mix(SNOW, SKY_BOT, (1 - f.z) * 0.3), lerp(0.55, 1, f.z));
}
}
function statusText() {
let up = 0;
for (const f of flakes) {
if (f.y < f.py) {
up += 1;
}
}
return '돌풍 세기 {g} · 올라가는 눈 {n}개'.replace('{g}', (GUST * gustAmp(sceneT)).toFixed(2)).replace('{n}', String(up));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// GustFieldSwirl - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GustFieldSwirl.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GustFieldSwirl() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 02 돌풍 장이 휘감는 눈 ────────────────────────────────────
// 돌풍 세기 — 장이 눈에 더하는 속도 배율
const GUST = 0.6;
// 돌풍 세기 1 · 봉우리 1 일 때 장 값 1 당 눈에 더하는 빠르기(px/초)
const GUST_SPEED = 200;
const FLAKES = 230;
const LOOP_S = 6;
const REST_STEPS = 60;
// 장의 사인 셋 — [가로 파수, 세로 파수, 시간 빠르기, 위상, 진폭]
const WAVES: number[][] = [
[0.021, 0.015, 0.9, 0.4, 1],
[-0.013, 0.027, -0.7, 2.1, 0.8],
[0.033, -0.011, 1.3, 4.0, 0.5]
];
type Flake = { x: number; y: number; px: number; py: number; z: number; s: number };
let flakes: Flake[] = [];
let rng = seeded(202);
// 장의 속도 — 흐름 함수 ψ = Σ a·sin(kx·x + ky·y + ω·t + φ) 의 회전(∂ψ/∂y, −∂ψ/∂x)이라 한 점으로 모이거나 퍼지지 않고 소용돌이로 돈다
function fieldAt(x: number, y: number, t: number): [number, number] {
let u = 0;
let v = 0;
for (const w of WAVES) {
const c = Math.cos(x * w[0] + y * w[1] + t * w[2] + w[3]) * w[4];
u += c * w[1];
v -= c * w[0];
}
return [u * 34, v * 34];
}
// 돌풍 봉우리 — 시간에 따라 세졌다 가라앉는 몫(0.3 ~ 1)
function gustAmp(t: number): number { return 0.65 + 0.35 * Math.sin(t * 1.4); }
function dropFlake(f: Flake, top: boolean): void {
f.x = rng() * (W + 40) - 20;
f.y = top ? -6 : rng() * H;
f.px = f.x;
f.py = f.y;
}
function reset(): void {
rng = seeded(202);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = 1.1 + Math.pow(rng(), 1.7) * 4.1;
const f: Flake = { x: 0, y: 0, px: 0, py: 0, z: (s - 1.1) / 4.1, s: s };
dropFlake(f, false);
flakes.push(f);
}
flakes.sort(function (a: Flake, b: Flake): number { return a.s - b.s; });
}
function update(dt: number): void {
const amp = GUST * GUST_SPEED * gustAmp(sceneT);
const user = userWind() * 70;
for (const f of flakes) {
f.px = f.x;
f.py = f.y;
// 반 걸음 적분 — 반 걸음 앞에서 장을 읽어 그 속도로 한 걸음 간다
const fall = 30 + 52 * f.z;
const depthK = 0.3 + 0.7 * f.z;
const a = fieldAt(f.x, f.y, sceneT);
const mx = f.x + (a[0] * amp * depthK + user) * dt * 0.5;
const my = f.y + (fall + a[1] * amp * depthK) * dt * 0.5;
const b = fieldAt(mx, my, sceneT);
f.x += (b[0] * amp * depthK + user) * dt;
f.y += (fall + b[1] * amp * depthK) * dt;
if (f.y > H + 8 || f.x > W + 30 || f.x < -30) { dropFlake(f, true); }
if (f.y < -40) { f.y = -6; f.px = f.x; f.py = f.y; }
}
}
function draw(): void {
skyFill(SKY_TOP, SKY_BOT);
// 장을 눈에 보이게 — 격자 한 칸마다 그 자리의 속도 방향 짧은 선
const amp = GUST * gustAmp(sceneT);
for (let gy = 20; gy < H; gy += 40) {
for (let gx = 20; gx < W; gx += 40) {
const a = fieldAt(gx, gy, sceneT);
const m = Math.hypot(a[0], a[1]);
if (m < 0.01) { continue; }
const len = Math.min(16, m * amp * 18);
seg(gx - a[0] / m * len * 0.5, gy - a[1] / m * len * 0.5, gx + a[0] / m * len * 0.5, gy + a[1] / m * len * 0.5, 2, SNOW_SHADE, 0.28);
}
}
for (const f of flakes) {
const sx = f.x - f.px;
const sy = f.y - f.py;
seg(f.px - sx * 2.2, f.py - sy * 2.2, f.x, f.y, f.s * 0.5, SNOW, 0.2 + 0.25 * f.z);
flakeDot(f.x, f.y, f.s * 0.62, (1 - f.z) * 1.6, mix(SNOW, SKY_BOT, (1 - f.z) * 0.3), lerp(0.55, 1, f.z));
}
}
function statusText(): string {
let up = 0;
for (const f of flakes) { if (f.y < f.py) { up += 1; } }
return '돌풍 세기 {g} · 올라가는 눈 {n}개'.replace('{g}', (GUST * gustAmp(sceneT)).toFixed(2)).replace('{n}', String(up));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'시간에 따라 움직이는 돌풍 장이 눈의 위치마다 회전하는 속도를 더해 눈이 한 줄로 떨어지다 휘감겨 올라갔다 가라앉고, 격자의 짧은 선이 장의 방향을 보여 준다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 돌풍 장이 휘감는 눈 — 측정 누적면적 41.808% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function fieldAt(x, y, t) {
let u = 0;
let v = 0;
for (const w of WAVES) {
const c = Math.cos(x * w[0] + y * w[1] + t * w[2] + w[3]) * w[4];
u += c * w[1];
v -= c * w[0];
}
return [u * 34, v * 34];
}
// 돌풍 봉우리 — 시간에 따라 세졌다 가라앉는 몫(0.3 ~ 1)
function gustAmp(t) { return 0.65 + 0.35 * Math.sin(t * 1.4); }
03쌓이며 무너지는 눈더미
연말 인사 페이지에서 글자 선반 위에 눈이 쌓이게 할 때 쓰면 좋다. 바닥에 닿은 눈은 열마다 둔 높이 지도에 3px 씩 더해지고, 이웃보다 REPOSE 한계를 넘게 높은 열은 넘은 만큼의 절반을 낮은 쪽으로 보낸다. 왼쪽 패널은 기울기 한계를 끈 실패판이고 오른쪽 패널이 한계를 지킨 대책이다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="왼쪽은 기울기 한계 없이 쌓여 탑이 서는 눈더미, 오른쪽은 기울기 한계를 넘으면 낮은 쪽으로 흘려 완만한 언덕이 되는 눈더미를 글자 모양 선반 위에 나란히 보여 준다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 03 쌓이며 무너지는 눈더미 ─────────────────────────────────
// 기울기 한계 — 이웃 열과의 눈 표면 높이 차를 몇 알(GRAIN 높이)까지 받아들이나. 넘은 만큼의 반을 낮은 쪽으로 흘린다
const REPOSE = 1;
// 눈 한 알이 한 열의 높이에 더하는 양(px)
const ADD = 3;
// 한 열의 폭(px)이자 높이 한 알(px)
const GRAIN = 3;
const FLAKES = 84;
const LOOP_S = 6;
const REST_STEPS = 45;
const PANEL_W = 240;
const COLS = PANEL_W / GRAIN;
const GROUND_Y = 262;
// 선반 — 글자 윗면처럼 높이가 다른 세 덩어리(px). 눈은 이 위에 쌓인다
const BASE = [];
for (let c = 0; c < COLS; c += 1) {
BASE.push(c >= 8 && c < 26 ? 18 : (c >= 34 && c < 46 ? 36 : (c >= 56 && c < 72 ? 12 : 0)));
}
let panels = [];
// 눈 줄기 — 바람이 눈을 모아 떨어뜨리는 세 자리(열)와 흩어진 눈
const STREAMS = [17, 40, 64];
function makePanel(limit) {
const rng = seeded(303);
const h = [];
for (let c = 0; c < COLS; c += 1) {
h.push(0);
}
const fl = [];
for (let i = 0; i < FLAKES; i += 1) {
fl.push({ x: 0, y: 0, v: 0, s: 0 });
}
const p = { h: h, fl: fl, limit: limit, rng: rng };
for (const f of fl) {
respawn(p, f);
f.y = rng() * GROUND_Y;
}
return p;
}
function respawn(p, f) {
const r = p.rng();
const col = r < 0.65 ? STREAMS[Math.floor(p.rng() * 3)] + (p.rng() + p.rng() + p.rng() - 1.5) * 3 : p.rng() * COLS;
f.x = clamp(col, 0, COLS - 1) * GRAIN + GRAIN * 0.5;
f.y = -4 - p.rng() * 30;
f.v = 95 + p.rng() * 40;
f.s = 1.4 + p.rng() * 1.6;
}
// 눈 표면 높이(px) — 선반 + 쌓인 눈
function surface(p, c) { return BASE[c] + p.h[c]; }
// 기울기 한계 — 한 열의 표면이 오른쪽·왼쪽 이웃보다 한계 넘게 높으면 넘은 만큼의 반을 낮은 쪽으로 보낸다. 네 번 되풀이해 무너짐이 번지게
function relax(p) {
if (p.limit >= 1e6) {
return;
}
const maxDiff = p.limit * GRAIN;
for (let pass = 0; pass < 4; pass += 1) {
for (let c = 0; c < COLS; c += 1) {
for (let d = -1; d <= 1; d += 2) {
const n = c + d;
if (n < 0 || n >= COLS) {
continue;
}
const excess = surface(p, c) - surface(p, n) - maxDiff;
if (excess > 0 && p.h[c] > 0) {
const move = Math.min(p.h[c], excess * 0.5);
p.h[c] -= move;
p.h[n] += move;
}
}
}
}
}
function stepPanel(p, dt) {
const wind = userWind() * 30;
for (const f of p.fl) {
f.y += f.v * dt;
f.x += wind * dt;
const c = clamp(Math.floor(f.x / GRAIN), 0, COLS - 1);
if (f.y >= GROUND_Y - surface(p, c)) {
// 바닥에 닿은 눈은 사라지지 않고 높이 지도에 더해진 뒤 위에서 다시 내려온다
p.h[c] += ADD;
respawn(p, f);
}
}
relax(p);
}
function reset() {
panels = [makePanel(1e9), makePanel(REPOSE)];
for (let k = 0; k < 55; k += 1) {
for (const p of panels) {
stepPanel(p, 1 / 30);
}
}
}
function update(dt) {
for (const p of panels) {
stepPanel(p, dt);
}
}
function peak(p) {
let m = 0;
for (let c = 0; c < COLS; c += 1) {
if (p.h[c] > m) {
m = p.h[c];
}
}
return m;
}
function drawPanel(p, ox) {
ctx.save();
ctx.beginPath();
ctx.rect(ox, 0, PANEL_W, H);
ctx.clip();
ctx.translate(ox, 0);
// 선반
for (let c = 0; c < COLS; c += 1) {
if (BASE[c] > 0) {
box(c * GRAIN, GROUND_Y - BASE[c], GRAIN + 0.6, BASE[c] + 40, 0, DUSK_DEEP, 1);
}
}
box(0, GROUND_Y, PANEL_W, H - GROUND_Y, 0, DUSK_DEEP, 1);
// 쌓인 눈 — 열마다 선반 윗면에서 눈 높이만큼만 채운다
for (let c = 0; c < COLS; c += 1) {
if (p.h[c] > 0) {
box(c * GRAIN, GROUND_Y - surface(p, c), GRAIN + 0.6, p.h[c], 0, SNOW, 1);
}
}
ctx.beginPath();
for (let c = 0; c < COLS; c += 1) {
const top = GROUND_Y - surface(p, c);
if (c === 0) {
ctx.moveTo(0, top + 1.5);
}
else {
ctx.lineTo(c * GRAIN, top + 1.5);
}
ctx.lineTo((c + 1) * GRAIN, top + 1.5);
}
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(SNOW_SHADE, 1);
ctx.stroke();
for (const f of p.fl) {
flakeDot(f.x, f.y, f.s, 0, SNOW, 0.95);
}
ctx.restore();
}
function draw() {
skyFill(SKY_TOP, SKY_BOT);
drawPanel(panels[0], 0);
drawPanel(panels[1], PANEL_W);
box(PANEL_W - 1, 0, 2, H, 0, INK, 0.55);
tag('기울기 한계 없음 · 가장 높은 눈 {n}px'.replace('{n}', String(Math.round(peak(panels[0])))), PANEL_W / 2, 280, PAPER, INK, 10, 'center');
tag('기울기 한계 {r} · 가장 높은 눈 {n}px'.replace('{r}', String(REPOSE)).replace('{n}', String(Math.round(peak(panels[1])))), PANEL_W * 1.5, 280, PAPER, INK, 10, 'center');
}
function statusText() {
return '한계 없음 {a}px · 한계 {r} {b}px'.replace('{a}', String(Math.round(peak(panels[0])))).replace('{r}', String(REPOSE)).replace('{b}', String(Math.round(peak(panels[1]))));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PileReposeHeightmap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PileReposeHeightmap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PileReposeHeightmap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 03 쌓이며 무너지는 눈더미 ─────────────────────────────────
// 기울기 한계 — 이웃 열과의 눈 표면 높이 차를 몇 알(GRAIN 높이)까지 받아들이나. 넘은 만큼의 반을 낮은 쪽으로 흘린다
const REPOSE = 1;
// 눈 한 알이 한 열의 높이에 더하는 양(px)
const ADD = 3;
// 한 열의 폭(px)이자 높이 한 알(px)
const GRAIN = 3;
const FLAKES = 84;
const LOOP_S = 6;
const REST_STEPS = 45;
const PANEL_W = 240;
const COLS = PANEL_W / GRAIN;
const GROUND_Y = 262;
type Flake = { x: number; y: number; v: number; s: number };
type Panel = { h: number[]; fl: Flake[]; limit: number; rng: () => number };
// 선반 — 글자 윗면처럼 높이가 다른 세 덩어리(px). 눈은 이 위에 쌓인다
const BASE: number[] = [];
for (let c = 0; c < COLS; c += 1) {
BASE.push(c >= 8 && c < 26 ? 18 : (c >= 34 && c < 46 ? 36 : (c >= 56 && c < 72 ? 12 : 0)));
}
let panels: Panel[] = [];
// 눈 줄기 — 바람이 눈을 모아 떨어뜨리는 세 자리(열)와 흩어진 눈
const STREAMS = [17, 40, 64];
function makePanel(limit: number): Panel {
const rng = seeded(303);
const h: number[] = [];
for (let c = 0; c < COLS; c += 1) { h.push(0); }
const fl: Flake[] = [];
for (let i = 0; i < FLAKES; i += 1) { fl.push({ x: 0, y: 0, v: 0, s: 0 }); }
const p: Panel = { h: h, fl: fl, limit: limit, rng: rng };
for (const f of fl) { respawn(p, f); f.y = rng() * GROUND_Y; }
return p;
}
function respawn(p: Panel, f: Flake): void {
const r = p.rng();
const col = r < 0.65 ? STREAMS[Math.floor(p.rng() * 3)] + (p.rng() + p.rng() + p.rng() - 1.5) * 3 : p.rng() * COLS;
f.x = clamp(col, 0, COLS - 1) * GRAIN + GRAIN * 0.5;
f.y = -4 - p.rng() * 30;
f.v = 95 + p.rng() * 40;
f.s = 1.4 + p.rng() * 1.6;
}
// 눈 표면 높이(px) — 선반 + 쌓인 눈
function surface(p: Panel, c: number): number { return BASE[c] + p.h[c]; }
// 기울기 한계 — 한 열의 표면이 오른쪽·왼쪽 이웃보다 한계 넘게 높으면 넘은 만큼의 반을 낮은 쪽으로 보낸다. 네 번 되풀이해 무너짐이 번지게
function relax(p: Panel): void {
if (p.limit >= 1e6) { return; }
const maxDiff = p.limit * GRAIN;
for (let pass = 0; pass < 4; pass += 1) {
for (let c = 0; c < COLS; c += 1) {
for (let d = -1; d <= 1; d += 2) {
const n = c + d;
if (n < 0 || n >= COLS) { continue; }
const excess = surface(p, c) - surface(p, n) - maxDiff;
if (excess > 0 && p.h[c] > 0) {
const move = Math.min(p.h[c], excess * 0.5);
p.h[c] -= move;
p.h[n] += move;
}
}
}
}
}
function stepPanel(p: Panel, dt: number): void {
const wind = userWind() * 30;
for (const f of p.fl) {
f.y += f.v * dt;
f.x += wind * dt;
const c = clamp(Math.floor(f.x / GRAIN), 0, COLS - 1);
if (f.y >= GROUND_Y - surface(p, c)) {
// 바닥에 닿은 눈은 사라지지 않고 높이 지도에 더해진 뒤 위에서 다시 내려온다
p.h[c] += ADD;
respawn(p, f);
}
}
relax(p);
}
function reset(): void {
panels = [makePanel(1e9), makePanel(REPOSE)];
for (let k = 0; k < 55; k += 1) {
for (const p of panels) { stepPanel(p, 1 / 30); }
}
}
function update(dt: number): void {
for (const p of panels) { stepPanel(p, dt); }
}
function peak(p: Panel): number {
let m = 0;
for (let c = 0; c < COLS; c += 1) { if (p.h[c] > m) { m = p.h[c]; } }
return m;
}
function drawPanel(p: Panel, ox: number): void {
ctx.save();
ctx.beginPath();
ctx.rect(ox, 0, PANEL_W, H);
ctx.clip();
ctx.translate(ox, 0);
// 선반
for (let c = 0; c < COLS; c += 1) {
if (BASE[c] > 0) { box(c * GRAIN, GROUND_Y - BASE[c], GRAIN + 0.6, BASE[c] + 40, 0, DUSK_DEEP, 1); }
}
box(0, GROUND_Y, PANEL_W, H - GROUND_Y, 0, DUSK_DEEP, 1);
// 쌓인 눈 — 열마다 선반 윗면에서 눈 높이만큼만 채운다
for (let c = 0; c < COLS; c += 1) {
if (p.h[c] > 0) { box(c * GRAIN, GROUND_Y - surface(p, c), GRAIN + 0.6, p.h[c], 0, SNOW, 1); }
}
ctx.beginPath();
for (let c = 0; c < COLS; c += 1) {
const top = GROUND_Y - surface(p, c);
if (c === 0) { ctx.moveTo(0, top + 1.5); } else { ctx.lineTo(c * GRAIN, top + 1.5); }
ctx.lineTo((c + 1) * GRAIN, top + 1.5);
}
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(SNOW_SHADE, 1);
ctx.stroke();
for (const f of p.fl) { flakeDot(f.x, f.y, f.s, 0, SNOW, 0.95); }
ctx.restore();
}
function draw(): void {
skyFill(SKY_TOP, SKY_BOT);
drawPanel(panels[0], 0);
drawPanel(panels[1], PANEL_W);
box(PANEL_W - 1, 0, 2, H, 0, INK, 0.55);
tag('기울기 한계 없음 · 가장 높은 눈 {n}px'.replace('{n}', String(Math.round(peak(panels[0])))), PANEL_W / 2, 280, PAPER, INK, 10, 'center');
tag('기울기 한계 {r} · 가장 높은 눈 {n}px'.replace('{r}', String(REPOSE)).replace('{n}', String(Math.round(peak(panels[1])))), PANEL_W * 1.5, 280, PAPER, INK, 10, 'center');
}
function statusText(): string {
return '한계 없음 {a}px · 한계 {r} {b}px'.replace('{a}', String(Math.round(peak(panels[0])))).replace('{r}', String(REPOSE)).replace('{b}', String(Math.round(peak(panels[1]))));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'왼쪽은 기울기 한계 없이 쌓여 탑이 서는 눈더미, 오른쪽은 기울기 한계를 넘으면 낮은 쪽으로 흘려 완만한 언덕이 되는 눈더미를 글자 모양 선반 위에 나란히 보여 준다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 쌓이며 무너지는 눈더미 — 측정 누적면적 27.164% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function relax(p) {
if (p.limit >= 1e6) {
return;
}
const maxDiff = p.limit * GRAIN;
for (let pass = 0; pass < 4; pass += 1) {
for (let c = 0; c < COLS; c += 1) {
for (let d = -1; d <= 1; d += 2) {
const n = c + d;
if (n < 0 || n >= COLS) {
continue;
}
const excess = surface(p, c) - surface(p, n) - maxDiff;
if (excess > 0 && p.h[c] > 0) {
const move = Math.min(p.h[c], excess * 0.5);
p.h[c] -= move;
p.h[n] += move;
}
}
}
}
}
04창유리에 닿아 녹는 눈
카페 소개 페이지의 창가 배경에 잘 맞는 장면이다. 눈이 유리에 닿으면 4px 칸의 물기를 올리고 방울이 그 아래로 내려가며 칸마다 물기를 남기고 질량을 깎는다. 물기는 EVAP 로 마르므로 자국이 길게 흘러내린 뒤 지워지고, 김서림 층은 물기가 있는 곳만 뚫어 건너편 불빛을 보인다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="김 서린 창유리에 눈송이가 닿아 녹으면 물방울이 아래로 흘러 맑은 물줄기 자국을 내고, 자국은 시간이 지나 마르며 건너편 카페 불빛이 비친다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 04 창유리에 닿아 녹는 눈 ─────────────────────────────────
// 유리 격자 한 칸(px) — 물기 w 는 칸마다 0 ~ 1
const CELL = 4;
const GC = W / CELL;
const GR = H / CELL;
// 물기가 마르는 빠르기(1/초) — 클수록 물줄기 자국이 빨리 사라진다
const EVAP = 0.55;
// 방울 한 개가 가진 물의 양 범위(질량)
const MASS_MIN = 0.5;
const MASS_MAX = 1.3;
// 칸에 남기는 물기와 방울이 줄이는 질량 몫(칸당)
const TRAIL_WET = 0.85;
const TRAIL_COST = 0.024;
const LAND_PER_S = 20;
const FLAKES = 70;
const LOOP_S = 6;
const REST_STEPS = 45;
let water = [];
let drops = [];
let lands = [];
let outside = [];
let rng = seeded(404);
let carry = 0;
// 안쪽 불빛 — 유리 건너편 카페의 보케 위치와 색
const BOKEH = [];
// 김서림 층 — 따로 그려 두고 물기가 있는 곳만 뚫어 건너편이 보이게
const fogCv = document.createElement('canvas');
fogCv.width = W;
fogCv.height = H;
const fogCtxRaw = fogCv.getContext('2d');
if (!fogCtxRaw) {
return;
}
const fogCtx = fogCtxRaw;
function reset() {
rng = seeded(404);
water = [];
for (let i = 0; i < GC * GR; i += 1) {
water.push(0);
}
drops = [];
lands = [];
outside = [];
carry = 0;
BOKEH.length = 0;
const r = seeded(405);
for (let i = 0; i < 22; i += 1) {
BOKEH.push([r() * W, 20 + r() * 220, 10 + r() * 22, r()]);
}
for (let i = 0; i < FLAKES; i += 1) {
outside.push({ x: rng() * W, y: rng() * H, v: 36 + rng() * 40, s: 0.8 + rng() * 1.6 });
}
for (let k = 0; k < 36; k += 1) {
update(1 / 30);
}
}
function landOne() {
const x = 12 + rng() * (W - 24);
const y = 14 + rng() * (H * 0.55);
lands.push({ x: x, y: y, age: 0 });
// 닿은 칸이 먼저 젖는다
const c = clamp(Math.floor(x / CELL), 0, GC - 1);
const rr = clamp(Math.floor(y / CELL), 0, GR - 1);
water[rr * GC + c] = Math.min(1, water[rr * GC + c] + 0.9);
drops.push({ x: x, y: y, m: MASS_MIN + rng() * (MASS_MAX - MASS_MIN), wait: 0.15 + rng() * 0.2 });
}
function update(dt) {
const wind = userWind() * 24;
for (const o of outside) {
o.y += o.v * dt;
o.x += wind * dt;
if (o.y > H + 4) {
o.y = -4;
o.x = rng() * W;
}
if (o.x > W + 4) {
o.x -= W + 8;
}
if (o.x < -4) {
o.x += W + 8;
}
}
carry += LAND_PER_S * dt;
while (carry >= 1) {
carry -= 1;
landOne();
}
for (const l of lands) {
l.age += dt;
}
lands = lands.filter(function (l) { return l.age < 0.6; });
// 중력 방향으로 번짐 — 방울은 질량이 클수록 빠르게 내려가며 지나간 칸을 적시고 질량을 잃는다
for (const d of drops) {
if (d.wait > 0) {
d.wait -= dt;
continue;
}
const v = 14 + 70 * d.m;
const steps = Math.max(1, Math.round(v * dt / CELL));
for (let s = 0; s < steps; s += 1) {
d.y += v * dt / steps;
d.x += Math.sin(d.y * 0.09 + d.m * 9) * 0.12;
const c = clamp(Math.floor(d.x / CELL), 0, GC - 1);
const rr = Math.floor(d.y / CELL);
if (rr >= 0 && rr < GR) {
const i = rr * GC + c;
water[i] = Math.min(1, Math.max(water[i], TRAIL_WET));
d.m -= TRAIL_COST;
}
}
}
drops = drops.filter(function (d) { return d.m > 0 && d.y < H; });
const keep = Math.exp(-EVAP * dt);
for (let i = 0; i < water.length; i += 1) {
water[i] *= keep;
}
}
function draw() {
// 건너편 — 따뜻한 실내 바탕과 보케
skyFill([146, 130, 182], [238, 204, 196]);
for (const b of BOKEH) {
disc(b[0], b[1], b[2], b[3] < 0.5 ? LAMP : ROSE, 0.55);
}
// 바깥 눈 — 유리 건너편에서 천천히 내린다
for (const o of outside) {
flakeDot(o.x, o.y, o.s, 1.6, SNOW, 0.8);
}
// 김서림 — 물기 칸만 뚫는다
fogCtx.globalCompositeOperation = 'source-over';
fogCtx.clearRect(0, 0, W, H);
fogCtx.fillStyle = rgba(mix(SNOW_SHADE, SNOW, 0.4), 0.55);
fogCtx.fillRect(0, 0, W, H);
fogCtx.globalCompositeOperation = 'destination-out';
for (let rr = 0; rr < GR; rr += 1) {
for (let c = 0; c < GC; c += 1) {
const w = water[rr * GC + c];
if (w > 0.03) {
fogCtx.fillStyle = 'rgba(0,0,0,' + clamp(w * 1.1, 0, 1).toFixed(3) + ')';
fogCtx.fillRect(c * CELL, rr * CELL, CELL, CELL);
}
}
}
ctx.drawImage(fogCv, 0, 0);
// 방울 머리 — 물줄기 끝의 밝은 점
for (const d of drops) {
if (d.wait <= 0) {
disc(d.x, d.y, 2 + d.m * 1.4, WATER, 0.9);
}
}
// 방금 닿은 눈 — 크게 보이다 줄어든다
for (const l of lands) {
const k = 1 - l.age / 0.6;
flakeDot(l.x, l.y, 2 + 5 * k, 2, SNOW, 0.5 + 0.5 * k);
}
// 창틀
box(0, 0, W, 6, 0, INK, 0.5);
box(0, H - 8, W, 8, 0, INK, 0.5);
}
function statusText() {
let wet = 0;
for (let i = 0; i < water.length; i += 1) {
if (water[i] > 0.08) {
wet += 1;
}
}
return '젖은 칸 {w}% · 흘러내리는 방울 {n}개'.replace('{w}', String(Math.round(wet / water.length * 100))).replace('{n}', String(drops.length));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// WindowMeltTrail - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./WindowMeltTrail.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function WindowMeltTrail() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 04 창유리에 닿아 녹는 눈 ─────────────────────────────────
// 유리 격자 한 칸(px) — 물기 w 는 칸마다 0 ~ 1
const CELL = 4;
const GC = W / CELL;
const GR = H / CELL;
// 물기가 마르는 빠르기(1/초) — 클수록 물줄기 자국이 빨리 사라진다
const EVAP = 0.55;
// 방울 한 개가 가진 물의 양 범위(질량)
const MASS_MIN = 0.5;
const MASS_MAX = 1.3;
// 칸에 남기는 물기와 방울이 줄이는 질량 몫(칸당)
const TRAIL_WET = 0.85;
const TRAIL_COST = 0.024;
const LAND_PER_S = 20;
const FLAKES = 70;
const LOOP_S = 6;
const REST_STEPS = 45;
let water: number[] = [];
type Drop = { x: number; y: number; m: number; wait: number };
type Land = { x: number; y: number; age: number };
type Out = { x: number; y: number; v: number; s: number };
let drops: Drop[] = [];
let lands: Land[] = [];
let outside: Out[] = [];
let rng = seeded(404);
let carry = 0;
// 안쪽 불빛 — 유리 건너편 카페의 보케 위치와 색
const BOKEH: number[][] = [];
// 김서림 층 — 따로 그려 두고 물기가 있는 곳만 뚫어 건너편이 보이게
const fogCv = document.createElement('canvas');
fogCv.width = W;
fogCv.height = H;
const fogCtxRaw = fogCv.getContext('2d');
if (!fogCtxRaw) { return; }
const fogCtx: CanvasRenderingContext2D = fogCtxRaw;
function reset(): void {
rng = seeded(404);
water = [];
for (let i = 0; i < GC * GR; i += 1) { water.push(0); }
drops = [];
lands = [];
outside = [];
carry = 0;
BOKEH.length = 0;
const r = seeded(405);
for (let i = 0; i < 22; i += 1) { BOKEH.push([r() * W, 20 + r() * 220, 10 + r() * 22, r()]); }
for (let i = 0; i < FLAKES; i += 1) { outside.push({ x: rng() * W, y: rng() * H, v: 36 + rng() * 40, s: 0.8 + rng() * 1.6 }); }
for (let k = 0; k < 36; k += 1) { update(1 / 30); }
}
function landOne(): void {
const x = 12 + rng() * (W - 24);
const y = 14 + rng() * (H * 0.55);
lands.push({ x: x, y: y, age: 0 });
// 닿은 칸이 먼저 젖는다
const c = clamp(Math.floor(x / CELL), 0, GC - 1);
const rr = clamp(Math.floor(y / CELL), 0, GR - 1);
water[rr * GC + c] = Math.min(1, water[rr * GC + c] + 0.9);
drops.push({ x: x, y: y, m: MASS_MIN + rng() * (MASS_MAX - MASS_MIN), wait: 0.15 + rng() * 0.2 });
}
function update(dt: number): void {
const wind = userWind() * 24;
for (const o of outside) {
o.y += o.v * dt;
o.x += wind * dt;
if (o.y > H + 4) { o.y = -4; o.x = rng() * W; }
if (o.x > W + 4) { o.x -= W + 8; }
if (o.x < -4) { o.x += W + 8; }
}
carry += LAND_PER_S * dt;
while (carry >= 1) { carry -= 1; landOne(); }
for (const l of lands) { l.age += dt; }
lands = lands.filter(function (l: Land): boolean { return l.age < 0.6; });
// 중력 방향으로 번짐 — 방울은 질량이 클수록 빠르게 내려가며 지나간 칸을 적시고 질량을 잃는다
for (const d of drops) {
if (d.wait > 0) { d.wait -= dt; continue; }
const v = 14 + 70 * d.m;
const steps = Math.max(1, Math.round(v * dt / CELL));
for (let s = 0; s < steps; s += 1) {
d.y += v * dt / steps;
d.x += Math.sin(d.y * 0.09 + d.m * 9) * 0.12;
const c = clamp(Math.floor(d.x / CELL), 0, GC - 1);
const rr = Math.floor(d.y / CELL);
if (rr >= 0 && rr < GR) {
const i = rr * GC + c;
water[i] = Math.min(1, Math.max(water[i], TRAIL_WET));
d.m -= TRAIL_COST;
}
}
}
drops = drops.filter(function (d: Drop): boolean { return d.m > 0 && d.y < H; });
const keep = Math.exp(-EVAP * dt);
for (let i = 0; i < water.length; i += 1) { water[i] *= keep; }
}
function draw(): void {
// 건너편 — 따뜻한 실내 바탕과 보케
skyFill([146, 130, 182], [238, 204, 196]);
for (const b of BOKEH) { disc(b[0], b[1], b[2], b[3] < 0.5 ? LAMP : ROSE, 0.55); }
// 바깥 눈 — 유리 건너편에서 천천히 내린다
for (const o of outside) { flakeDot(o.x, o.y, o.s, 1.6, SNOW, 0.8); }
// 김서림 — 물기 칸만 뚫는다
fogCtx.globalCompositeOperation = 'source-over';
fogCtx.clearRect(0, 0, W, H);
fogCtx.fillStyle = rgba(mix(SNOW_SHADE, SNOW, 0.4), 0.55);
fogCtx.fillRect(0, 0, W, H);
fogCtx.globalCompositeOperation = 'destination-out';
for (let rr = 0; rr < GR; rr += 1) {
for (let c = 0; c < GC; c += 1) {
const w = water[rr * GC + c];
if (w > 0.03) {
fogCtx.fillStyle = 'rgba(0,0,0,' + clamp(w * 1.1, 0, 1).toFixed(3) + ')';
fogCtx.fillRect(c * CELL, rr * CELL, CELL, CELL);
}
}
}
ctx.drawImage(fogCv, 0, 0);
// 방울 머리 — 물줄기 끝의 밝은 점
for (const d of drops) {
if (d.wait <= 0) { disc(d.x, d.y, 2 + d.m * 1.4, WATER, 0.9); }
}
// 방금 닿은 눈 — 크게 보이다 줄어든다
for (const l of lands) {
const k = 1 - l.age / 0.6;
flakeDot(l.x, l.y, 2 + 5 * k, 2, SNOW, 0.5 + 0.5 * k);
}
// 창틀
box(0, 0, W, 6, 0, INK, 0.5);
box(0, H - 8, W, 8, 0, INK, 0.5);
}
function statusText(): string {
let wet = 0;
for (let i = 0; i < water.length; i += 1) { if (water[i] > 0.08) { wet += 1; } }
return '젖은 칸 {w}% · 흘러내리는 방울 {n}개'.replace('{w}', String(Math.round(wet / water.length * 100))).replace('{n}', String(drops.length));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'김 서린 창유리에 눈송이가 닿아 녹으면 물방울이 아래로 흘러 맑은 물줄기 자국을 내고, 자국은 시간이 지나 마르며 건너편 카페 불빛이 비친다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 창유리에 닿아 녹는 눈 — 측정 누적면적 21.645% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 중력 방향으로 번짐 — 방울은 질량이 클수록 빠르게 내려가며 지나간 칸을 적시고 질량을 잃는다
for (const d of drops) {
if (d.wait > 0) {
d.wait -= dt;
continue;
}
const v = 14 + 70 * d.m;
const steps = Math.max(1, Math.round(v * dt / CELL));
for (let s = 0; s < steps; s += 1) {
d.y += v * dt / steps;
d.x += Math.sin(d.y * 0.09 + d.m * 9) * 0.12;
const c = clamp(Math.floor(d.x / CELL), 0, GC - 1);
const rr = Math.floor(d.y / CELL);
if (rr >= 0 && rr < GR) {
const i = rr * GC + c;
water[i] = Math.min(1, Math.max(water[i], TRAIL_WET));
d.m -= TRAIL_COST;
}
}
}
05흔들었다 가라앉는 눈 유리구슬
선물 가게 소개 페이지의 장식 카드로 쓴다. 정해진 시각표(0.25초·3.6초)에 쌓인 눈이 입자로 돌아와 충격을 받고, 공기 저항이 속도를 깎아 입자는 초당 약 49.6px 로 가라앉으며, 바닥 가까이(FLOOR_SLACK 4px 안)에서 속도가 40 아래로 내려온 입자는 바닥 눈더미에 PILE_ADD 1.5px 씩 더해진다. 구슬 벽에서는 바깥으로 향하는 속도 성분만 뒤집고 RESTITUTION 0.45 만큼 남긴다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="정해진 시각표대로 흔들린 유리구슬 안에서 눈 입자가 벽에 튕겨 흩날리다 공기 저항으로 느려져 바닥에 천천히 가라앉아 쌓인다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 05 흔들었다 가라앉는 눈 유리구슬 ──────────────────────────
// 중력(px/초²)과 공기 저항 몫(1/초) — 끝 속도는 중력 ÷ 저항 = 약 50px/초
const GRAVITY = 62;
const DRAG = 1.25;
// 흔들 때 눈에 주는 충격 크기(px/초)
const SHAKE = 210;
// 벽에서 튕길 때 법선 방향 속도가 반대로 남는 몫
const RESTITUTION = 0.45;
// 흔드는 시각표(초) — 사람이 아니라 시간표로 고정한다
const SHAKE_AT = [0.25, 3.6];
const GRAINS = 240;
const LOOP_S = 7;
const REST_STEPS = 150;
const CX = 240;
const CY = 140;
const R = 116;
// 바닥 눈더미 구간 — 구슬 안쪽 아래 호를 이 폭의 칸으로 나눈다
const BIN_W = 8;
const BINS = 26;
const PILE_ADD = 1.5;
// 바닥 접촉 여유(px) — 비스듬한 벽에 닿은 눈알도 바닥에 닿은 것으로 센다
const FLOOR_SLACK = 4;
let grains = [];
let pile = [];
let rng = seeded(505);
let shakeSeen = 0;
let kick = 0;
// 바닥 곡선 — 구슬 안쪽 아래 호의 높이
function floorY(x) { return CY + Math.sqrt(Math.max(0, (R - 4) * (R - 4) - (x - CX) * (x - CX))); }
function binOf(x) { return clamp(Math.floor((x - (CX - BINS * BIN_W / 2)) / BIN_W), 0, BINS - 1); }
function binX(b) { return CX - BINS * BIN_W / 2 + (b + 0.5) * BIN_W; }
function reset() {
rng = seeded(505);
grains = [];
pile = [];
shakeSeen = 0;
kick = 0;
for (let b = 0; b < BINS; b += 1) {
pile.push(0);
}
// 처음에는 모든 눈이 바닥에 가라앉아 있다 — 가운데가 높은 더미로 시작
for (let i = 0; i < GRAINS; i += 1) {
const b = clamp(Math.round(BINS / 2 + (rng() + rng() + rng() - 1.5) * 6), 0, BINS - 1);
pile[b] += PILE_ADD;
}
}
function releasePile() {
// 흔들면 쌓인 눈이 모두 입자로 돌아와 충격을 받는다
for (let b = 0; b < BINS; b += 1) {
const n = Math.round(pile[b] / PILE_ADD);
for (let k = 0; k < n; k += 1) {
const x = binX(b) + (rng() - 0.5) * BIN_W;
grains.push({ x: x, y: floorY(x) - 2 - rng() * pile[b], vx: 0, vy: 0, s: 1.2 + rng() * 1.5 });
}
pile[b] = 0;
}
}
function update(dt) {
// 시각표를 지난 흔들기마다 한 번씩 충격
while (shakeSeen < SHAKE_AT.length && sceneT >= SHAKE_AT[shakeSeen]) {
shakeSeen += 1;
kick = 1;
releasePile();
for (const g of grains) {
g.vx += (rng() - 0.5) * 2 * SHAKE;
g.vy += (rng() - 0.75) * 2 * SHAKE;
}
}
kick = Math.max(0, kick - dt * 2.4);
const wind = userWind() * 40;
const rest = [];
for (const g of grains) {
g.vy += GRAVITY * dt;
g.vx += wind * dt;
// 공기 저항 — 속도에 비례해 깎는다
const drag = Math.exp(-DRAG * dt);
g.vx *= drag;
g.vy *= drag;
g.x += g.vx * dt;
g.y += g.vy * dt;
const dx = g.x - CX;
const dy = g.y - CY;
const d = Math.hypot(dx, dy);
const lim = R - 5 - g.s;
if (d > lim) {
// 벽 반사 — 바깥으로 향하는 법선 방향 속도 성분만 뒤집고 RESTITUTION 만큼 남긴다
const nx = dx / d;
const ny = dy / d;
const vn = g.vx * nx + g.vy * ny;
if (vn > 0) {
g.vx -= (1 + RESTITUTION) * vn * nx;
g.vy -= (1 + RESTITUTION) * vn * ny;
}
g.x = CX + nx * lim;
g.y = CY + ny * lim;
}
const b = binOf(g.x);
if (g.y + g.s >= floorY(g.x) - pile[b] - 1 - FLOOR_SLACK && Math.hypot(g.vx, g.vy) < 40) {
pile[b] += PILE_ADD;
}
else {
rest.push(g);
}
}
grains = rest;
// 더미 평탄화 — 이웃보다 두 알(PILE_ADD 의 2배) 넘게 차이 나면 차이의 4분의 1 을 낮은 쪽으로 옮긴다
for (let pass = 0; pass < 2; pass += 1) {
for (let b = 0; b < BINS - 1; b += 1) {
const diff = pile[b] - pile[b + 1];
if (Math.abs(diff) > PILE_ADD * 2) {
const m = diff * 0.25;
pile[b] -= m;
pile[b + 1] += m;
}
}
}
}
function draw() {
skyFill(NIGHT_TOP, NIGHT_BOT);
ctx.save();
// 흔들림 — 충격 직후 구슬 전체가 잠깐 떤다
ctx.translate(Math.sin(sceneT * 70) * 4 * kick, Math.cos(sceneT * 61) * 3 * kick);
// 받침
box(CX - 96, CY + R - 12, 192, 54, 10, DUSK_DEEP, 1);
box(CX - 96, CY + R - 12, 192, 7, 3, ROSE, 1);
// 구슬 안
ctx.save();
ctx.beginPath();
ctx.arc(CX, CY, R, 0, TAU);
ctx.clip();
skyFill(mix(SKY_TOP, NIGHT_BOT, 0.2), SKY_BOT);
// 안쪽 작은 마을 실루엣
for (let i = 0; i < 6; i += 1) {
const bx = CX - 80 + i * 30;
box(bx, CY + 52 - (i % 3) * 10, 22, 70, 2, DUSK, 1);
box(bx + 6, CY + 60 - (i % 3) * 10, 5, 7, 1, LAMP, 0.9);
}
// 쌓인 눈
ctx.beginPath();
ctx.moveTo(CX - BINS * BIN_W / 2, CY + R);
for (let b = 0; b < BINS; b += 1) {
const x = binX(b);
ctx.lineTo(x, floorY(x) - pile[b]);
}
ctx.lineTo(CX + BINS * BIN_W / 2, CY + R);
ctx.closePath();
ctx.fillStyle = rgba(SNOW, 1);
ctx.fill();
for (const g of grains) {
flakeDot(g.x, g.y, g.s, 0, SNOW, 0.95);
}
ctx.restore();
// 유리 테두리와 반사
ring(CX, CY, R, 3, SNOW, 0.7);
ctx.beginPath();
ctx.arc(CX, CY, R - 10, Math.PI * 1.08, Math.PI * 1.36);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(SNOW, 0.55);
ctx.stroke();
ctx.restore();
}
function statusText() {
let top = 0;
for (const p of pile) {
top = Math.max(top, p);
}
return '떠다니는 눈 {n}알 · 바닥 눈 높이 {h}px'.replace('{n}', String(grains.length)).replace('{h}', String(Math.round(top)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// GlobeShakeSettle - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./GlobeShakeSettle.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function GlobeShakeSettle() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 05 흔들었다 가라앉는 눈 유리구슬 ──────────────────────────
// 중력(px/초²)과 공기 저항 몫(1/초) — 끝 속도는 중력 ÷ 저항 = 약 50px/초
const GRAVITY = 62;
const DRAG = 1.25;
// 흔들 때 눈에 주는 충격 크기(px/초)
const SHAKE = 210;
// 벽에서 튕길 때 법선 방향 속도가 반대로 남는 몫
const RESTITUTION = 0.45;
// 흔드는 시각표(초) — 사람이 아니라 시간표로 고정한다
const SHAKE_AT: number[] = [0.25, 3.6];
const GRAINS = 240;
const LOOP_S = 7;
const REST_STEPS = 150;
const CX = 240;
const CY = 140;
const R = 116;
// 바닥 눈더미 구간 — 구슬 안쪽 아래 호를 이 폭의 칸으로 나눈다
const BIN_W = 8;
const BINS = 26;
const PILE_ADD = 1.5;
// 바닥 접촉 여유(px) — 비스듬한 벽에 닿은 눈알도 바닥에 닿은 것으로 센다
const FLOOR_SLACK = 4;
type Grain = { x: number; y: number; vx: number; vy: number; s: number };
let grains: Grain[] = [];
let pile: number[] = [];
let rng = seeded(505);
let shakeSeen = 0;
let kick = 0;
// 바닥 곡선 — 구슬 안쪽 아래 호의 높이
function floorY(x: number): number { return CY + Math.sqrt(Math.max(0, (R - 4) * (R - 4) - (x - CX) * (x - CX))); }
function binOf(x: number): number { return clamp(Math.floor((x - (CX - BINS * BIN_W / 2)) / BIN_W), 0, BINS - 1); }
function binX(b: number): number { return CX - BINS * BIN_W / 2 + (b + 0.5) * BIN_W; }
function reset(): void {
rng = seeded(505);
grains = [];
pile = [];
shakeSeen = 0;
kick = 0;
for (let b = 0; b < BINS; b += 1) { pile.push(0); }
// 처음에는 모든 눈이 바닥에 가라앉아 있다 — 가운데가 높은 더미로 시작
for (let i = 0; i < GRAINS; i += 1) {
const b = clamp(Math.round(BINS / 2 + (rng() + rng() + rng() - 1.5) * 6), 0, BINS - 1);
pile[b] += PILE_ADD;
}
}
function releasePile(): void {
// 흔들면 쌓인 눈이 모두 입자로 돌아와 충격을 받는다
for (let b = 0; b < BINS; b += 1) {
const n = Math.round(pile[b] / PILE_ADD);
for (let k = 0; k < n; k += 1) {
const x = binX(b) + (rng() - 0.5) * BIN_W;
grains.push({ x: x, y: floorY(x) - 2 - rng() * pile[b], vx: 0, vy: 0, s: 1.2 + rng() * 1.5 });
}
pile[b] = 0;
}
}
function update(dt: number): void {
// 시각표를 지난 흔들기마다 한 번씩 충격
while (shakeSeen < SHAKE_AT.length && sceneT >= SHAKE_AT[shakeSeen]) {
shakeSeen += 1;
kick = 1;
releasePile();
for (const g of grains) {
g.vx += (rng() - 0.5) * 2 * SHAKE;
g.vy += (rng() - 0.75) * 2 * SHAKE;
}
}
kick = Math.max(0, kick - dt * 2.4);
const wind = userWind() * 40;
const rest: Grain[] = [];
for (const g of grains) {
g.vy += GRAVITY * dt;
g.vx += wind * dt;
// 공기 저항 — 속도에 비례해 깎는다
const drag = Math.exp(-DRAG * dt);
g.vx *= drag;
g.vy *= drag;
g.x += g.vx * dt;
g.y += g.vy * dt;
const dx = g.x - CX;
const dy = g.y - CY;
const d = Math.hypot(dx, dy);
const lim = R - 5 - g.s;
if (d > lim) {
// 벽 반사 — 바깥으로 향하는 법선 방향 속도 성분만 뒤집고 RESTITUTION 만큼 남긴다
const nx = dx / d;
const ny = dy / d;
const vn = g.vx * nx + g.vy * ny;
if (vn > 0) {
g.vx -= (1 + RESTITUTION) * vn * nx;
g.vy -= (1 + RESTITUTION) * vn * ny;
}
g.x = CX + nx * lim;
g.y = CY + ny * lim;
}
const b = binOf(g.x);
if (g.y + g.s >= floorY(g.x) - pile[b] - 1 - FLOOR_SLACK && Math.hypot(g.vx, g.vy) < 40) {
pile[b] += PILE_ADD;
} else {
rest.push(g);
}
}
grains = rest;
// 더미 평탄화 — 이웃보다 두 알(PILE_ADD 의 2배) 넘게 차이 나면 차이의 4분의 1 을 낮은 쪽으로 옮긴다
for (let pass = 0; pass < 2; pass += 1) {
for (let b = 0; b < BINS - 1; b += 1) {
const diff = pile[b] - pile[b + 1];
if (Math.abs(diff) > PILE_ADD * 2) {
const m = diff * 0.25;
pile[b] -= m;
pile[b + 1] += m;
}
}
}
}
function draw(): void {
skyFill(NIGHT_TOP, NIGHT_BOT);
ctx.save();
// 흔들림 — 충격 직후 구슬 전체가 잠깐 떤다
ctx.translate(Math.sin(sceneT * 70) * 4 * kick, Math.cos(sceneT * 61) * 3 * kick);
// 받침
box(CX - 96, CY + R - 12, 192, 54, 10, DUSK_DEEP, 1);
box(CX - 96, CY + R - 12, 192, 7, 3, ROSE, 1);
// 구슬 안
ctx.save();
ctx.beginPath();
ctx.arc(CX, CY, R, 0, TAU);
ctx.clip();
skyFill(mix(SKY_TOP, NIGHT_BOT, 0.2), SKY_BOT);
// 안쪽 작은 마을 실루엣
for (let i = 0; i < 6; i += 1) {
const bx = CX - 80 + i * 30;
box(bx, CY + 52 - (i % 3) * 10, 22, 70, 2, DUSK, 1);
box(bx + 6, CY + 60 - (i % 3) * 10, 5, 7, 1, LAMP, 0.9);
}
// 쌓인 눈
ctx.beginPath();
ctx.moveTo(CX - BINS * BIN_W / 2, CY + R);
for (let b = 0; b < BINS; b += 1) {
const x = binX(b);
ctx.lineTo(x, floorY(x) - pile[b]);
}
ctx.lineTo(CX + BINS * BIN_W / 2, CY + R);
ctx.closePath();
ctx.fillStyle = rgba(SNOW, 1);
ctx.fill();
for (const g of grains) { flakeDot(g.x, g.y, g.s, 0, SNOW, 0.95); }
ctx.restore();
// 유리 테두리와 반사
ring(CX, CY, R, 3, SNOW, 0.7);
ctx.beginPath();
ctx.arc(CX, CY, R - 10, Math.PI * 1.08, Math.PI * 1.36);
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(SNOW, 0.55);
ctx.stroke();
ctx.restore();
}
function statusText(): string {
let top = 0;
for (const p of pile) { top = Math.max(top, p); }
return '떠다니는 눈 {n}알 · 바닥 눈 높이 {h}px'.replace('{n}', String(grains.length)).replace('{h}', String(Math.round(top)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'정해진 시각표대로 흔들린 유리구슬 안에서 눈 입자가 벽에 튕겨 흩날리다 공기 저항으로 느려져 바닥에 천천히 가라앉아 쌓인다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 흔들었다 가라앉는 눈 유리구슬 — 측정 누적면적 20.46% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 공기 저항 — 속도에 비례해 깎는다
const drag = Math.exp(-DRAG * dt);
g.vx *= drag;
g.vy *= drag;
g.x += g.vx * dt;
g.y += g.vy * dt;
const dx = g.x - CX;
const dy = g.y - CY;
const d = Math.hypot(dx, dy);
const lim = R - 5 - g.s;
if (d > lim) {
// 벽 반사 — 바깥으로 향하는 법선 방향 속도 성분만 뒤집고 RESTITUTION 만큼 남긴다
const nx = dx / d;
const ny = dy / d;
const vn = g.vx * nx + g.vy * ny;
if (vn > 0) {
g.vx -= (1 + RESTITUTION) * vn * nx;
g.vy -= (1 + RESTITUTION) * vn * ny;
}
g.x = CX + nx * lim;
g.y = CY + ny * lim;
}
06가로등 빛 원뿔 속 눈
야간 모드 서비스 소개의 히어로 배경에 어울린다. 눈 한 알의 밝기는 원뿔 각도 몫 × 거리 감쇠 × 앞쪽 산란으로 정하고, 원뿔 밖은 AMBIENT 0.16 만 남아 어둡다. 원뿔 축은 사인으로 좌우로 쓸려서 밝은 눈 무리가 계속 바뀐다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="가로등 빛 원뿔 안의 눈만 거리와 각도에 따라 하얗게 빛나고 가장자리 눈은 앞쪽 산란으로 더 밝아, 어둠 속에 빛기둥이 떠올라 좌우로 쓸린다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 06 가로등 빛 원뿔 속 눈 ───────────────────────────────────
// 원뿔 반각(라디안)과 가장자리 쪽 밝기 떨어지는 차수
const CONE_HALF = 0.62;
const CONE_POWER = 2;
// 거리 감쇠 — 밝기 ÷ (1 + (d ÷ LAMP_RANGE)²)
const LAMP_RANGE = 170;
// 원뿔 밖 눈이 가진 주변광 밝기
const AMBIENT = 0.16;
// 앞쪽 산란 세기와 위상 비대칭(g)
const SCATTER = 1.1;
const PHASE_G = 0.55;
const FLAKES = 300;
const LOOP_S = 6;
const REST_STEPS = 40;
const LAMP_X = 150;
const LAMP_Y = 66;
let flakes = [];
let rng = seeded(606);
// 가로등이 흔들리는 각 — 사인으로 오가며 원뿔이 좌우로 쓸린다
function axisAngle(t) { return Math.PI / 2 - 0.55 + Math.sin(t * 1.05) * 0.5; }
function reset() {
rng = seeded(606);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = 1.2 + Math.pow(rng(), 1.8) * 3.4;
flakes.push({ x: rng() * (W + 40) - 20, y: rng() * (H + 20) - 10, z: rng(), s: s, ph: rng() * TAU, lit: 0 });
}
}
// 눈 한 알의 밝기 — 원뿔 몫 × 거리 감쇠 × (1 + 앞쪽 산란), 원뿔 밖은 AMBIENT
function brightness(f, axis) {
const dx = f.x - LAMP_X;
const dy = f.y - LAMP_Y;
const d = Math.hypot(dx, dy);
if (d < 1) {
return 1;
}
let th = Math.abs(Math.atan2(dy, dx) - axis);
if (th > Math.PI) {
th = TAU - th;
}
const t = th / CONE_HALF;
const cone = t < 1 ? Math.pow(Math.cos(t * Math.PI / 2), CONE_POWER) : 0;
const fall = 1 / (1 + (d / LAMP_RANGE) * (d / LAMP_RANGE));
// 앞쪽 산란 — 빛이 눈을 지나 카메라 쪽으로 가는 정도. 카메라에 가까운 눈(z 큼)일수록, 원뿔 가운데와 바깥에서는 0 이고 반각의 절반 부근에서 가장 크다
const toward = clamp(f.z * 1.2 - 0.2, 0, 1) * Math.sin(Math.min(1, t) * Math.PI);
const cosA = clamp(toward, 0, 1);
const hg = (1 - PHASE_G * PHASE_G) / Math.pow(1 + PHASE_G * PHASE_G - 2 * PHASE_G * cosA, 1.5);
const scat = SCATTER * hg * 0.14;
return clamp(AMBIENT + cone * fall * (0.9 + scat), 0, 1.4);
}
function update(dt) {
const wind = autoOn ? Math.sin(sceneT * 0.8) * 0.5 : userWind();
for (const f of flakes) {
f.y += (26 + 46 * f.z) * dt;
f.x += wind * 30 * (0.4 + f.z) * dt + Math.sin(sceneT * 1.6 + f.ph) * 8 * dt;
if (f.y > H + 8) {
f.y -= H + 16;
}
if (f.x > W + 20) {
f.x -= W + 40;
}
if (f.x < -20) {
f.x += W + 40;
}
}
}
function draw() {
skyFill(NIGHT_TOP, NIGHT_BOT);
const axis = axisAngle(sceneT);
// 빛 원뿔 — 어둠 속에 떠오르는 부채꼴
ctx.beginPath();
ctx.moveTo(LAMP_X, LAMP_Y);
ctx.arc(LAMP_X, LAMP_Y, 330, axis - CONE_HALF, axis + CONE_HALF);
ctx.closePath();
const g = ctx.createRadialGradient(LAMP_X, LAMP_Y, 6, LAMP_X, LAMP_Y, 300);
g.addColorStop(0, rgba(LAMP, 0.5));
g.addColorStop(1, rgba(LAMP, 0));
ctx.fillStyle = g;
ctx.fill();
// 땅
box(0, 258, W, 42, 0, DUSK_DEEP, 1);
// 가로등
box(LAMP_X - 3, LAMP_Y, 6, 194, 2, INK, 1);
box(LAMP_X - 12, LAMP_Y - 12, 24, 14, 6, INK, 1);
disc(LAMP_X, LAMP_Y + 2, 7, LAMP, 1);
disc(LAMP_X, LAMP_Y + 2, 16, LAMP, 0.3);
let lit = 0;
for (const f of flakes) {
const b = brightness(f, axis);
f.lit = b;
if (b > 0.45) {
lit += 1;
}
const tw = b > 0.45 ? 0.82 + 0.18 * Math.sin(sceneT * 9 + f.ph) : 1;
const c = mix(SNOW_SHADE, SNOW, clamp(b, 0, 1));
flakeDot(f.x, f.y, f.s * 0.6, b > 0.6 ? 1.8 : 0, c, clamp(0.25 + 0.75 * b, 0, 1) * tw);
}
litCount = lit;
}
let litCount = 0;
function statusText() {
let top = 0;
for (const f of flakes) {
top = Math.max(top, f.lit);
}
return '빛 속 눈 {n}개 · 가장 밝은 눈 {b}%'.replace('{n}', String(litCount)).replace('{b}', String(Math.round(clamp(top, 0, 1) * 100)));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// LampConeGlow - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./LampConeGlow.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LampConeGlow() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 06 가로등 빛 원뿔 속 눈 ───────────────────────────────────
// 원뿔 반각(라디안)과 가장자리 쪽 밝기 떨어지는 차수
const CONE_HALF = 0.62;
const CONE_POWER = 2;
// 거리 감쇠 — 밝기 ÷ (1 + (d ÷ LAMP_RANGE)²)
const LAMP_RANGE = 170;
// 원뿔 밖 눈이 가진 주변광 밝기
const AMBIENT = 0.16;
// 앞쪽 산란 세기와 위상 비대칭(g)
const SCATTER = 1.1;
const PHASE_G = 0.55;
const FLAKES = 300;
const LOOP_S = 6;
const REST_STEPS = 40;
const LAMP_X = 150;
const LAMP_Y = 66;
type Flake = { x: number; y: number; z: number; s: number; ph: number; lit: number };
let flakes: Flake[] = [];
let rng = seeded(606);
// 가로등이 흔들리는 각 — 사인으로 오가며 원뿔이 좌우로 쓸린다
function axisAngle(t: number): number { return Math.PI / 2 - 0.55 + Math.sin(t * 1.05) * 0.5; }
function reset(): void {
rng = seeded(606);
flakes = [];
for (let i = 0; i < FLAKES; i += 1) {
const s = 1.2 + Math.pow(rng(), 1.8) * 3.4;
flakes.push({ x: rng() * (W + 40) - 20, y: rng() * (H + 20) - 10, z: rng(), s: s, ph: rng() * TAU, lit: 0 });
}
}
// 눈 한 알의 밝기 — 원뿔 몫 × 거리 감쇠 × (1 + 앞쪽 산란), 원뿔 밖은 AMBIENT
function brightness(f: Flake, axis: number): number {
const dx = f.x - LAMP_X;
const dy = f.y - LAMP_Y;
const d = Math.hypot(dx, dy);
if (d < 1) { return 1; }
let th = Math.abs(Math.atan2(dy, dx) - axis);
if (th > Math.PI) { th = TAU - th; }
const t = th / CONE_HALF;
const cone = t < 1 ? Math.pow(Math.cos(t * Math.PI / 2), CONE_POWER) : 0;
const fall = 1 / (1 + (d / LAMP_RANGE) * (d / LAMP_RANGE));
// 앞쪽 산란 — 빛이 눈을 지나 카메라 쪽으로 가는 정도. 카메라에 가까운 눈(z 큼)일수록, 원뿔 가운데와 바깥에서는 0 이고 반각의 절반 부근에서 가장 크다
const toward = clamp(f.z * 1.2 - 0.2, 0, 1) * Math.sin(Math.min(1, t) * Math.PI);
const cosA = clamp(toward, 0, 1);
const hg = (1 - PHASE_G * PHASE_G) / Math.pow(1 + PHASE_G * PHASE_G - 2 * PHASE_G * cosA, 1.5);
const scat = SCATTER * hg * 0.14;
return clamp(AMBIENT + cone * fall * (0.9 + scat), 0, 1.4);
}
function update(dt: number): void {
const wind = autoOn ? Math.sin(sceneT * 0.8) * 0.5 : userWind();
for (const f of flakes) {
f.y += (26 + 46 * f.z) * dt;
f.x += wind * 30 * (0.4 + f.z) * dt + Math.sin(sceneT * 1.6 + f.ph) * 8 * dt;
if (f.y > H + 8) { f.y -= H + 16; }
if (f.x > W + 20) { f.x -= W + 40; }
if (f.x < -20) { f.x += W + 40; }
}
}
function draw(): void {
skyFill(NIGHT_TOP, NIGHT_BOT);
const axis = axisAngle(sceneT);
// 빛 원뿔 — 어둠 속에 떠오르는 부채꼴
ctx.beginPath();
ctx.moveTo(LAMP_X, LAMP_Y);
ctx.arc(LAMP_X, LAMP_Y, 330, axis - CONE_HALF, axis + CONE_HALF);
ctx.closePath();
const g = ctx.createRadialGradient(LAMP_X, LAMP_Y, 6, LAMP_X, LAMP_Y, 300);
g.addColorStop(0, rgba(LAMP, 0.5));
g.addColorStop(1, rgba(LAMP, 0));
ctx.fillStyle = g;
ctx.fill();
// 땅
box(0, 258, W, 42, 0, DUSK_DEEP, 1);
// 가로등
box(LAMP_X - 3, LAMP_Y, 6, 194, 2, INK, 1);
box(LAMP_X - 12, LAMP_Y - 12, 24, 14, 6, INK, 1);
disc(LAMP_X, LAMP_Y + 2, 7, LAMP, 1);
disc(LAMP_X, LAMP_Y + 2, 16, LAMP, 0.3);
let lit = 0;
for (const f of flakes) {
const b = brightness(f, axis);
f.lit = b;
if (b > 0.45) { lit += 1; }
const tw = b > 0.45 ? 0.82 + 0.18 * Math.sin(sceneT * 9 + f.ph) : 1;
const c = mix(SNOW_SHADE, SNOW, clamp(b, 0, 1));
flakeDot(f.x, f.y, f.s * 0.6, b > 0.6 ? 1.8 : 0, c, clamp(0.25 + 0.75 * b, 0, 1) * tw);
}
litCount = lit;
}
let litCount = 0;
function statusText(): string {
let top = 0;
for (const f of flakes) { top = Math.max(top, f.lit); }
return '빛 속 눈 {n}개 · 가장 밝은 눈 {b}%'.replace('{n}', String(litCount)).replace('{b}', String(Math.round(clamp(top, 0, 1) * 100)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'가로등 빛 원뿔 안의 눈만 거리와 각도에 따라 하얗게 빛나고 가장자리 눈은 앞쪽 산란으로 더 밝아, 어둠 속에 빛기둥이 떠올라 좌우로 쓸린다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 가로등 빛 원뿔 속 눈 — 측정 누적면적 48.378% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 눈 한 알의 밝기 — 원뿔 몫 × 거리 감쇠 × (1 + 앞쪽 산란), 원뿔 밖은 AMBIENT
function brightness(f, axis) {
const dx = f.x - LAMP_X;
const dy = f.y - LAMP_Y;
const d = Math.hypot(dx, dy);
if (d < 1) {
return 1;
}
let th = Math.abs(Math.atan2(dy, dx) - axis);
if (th > Math.PI) {
th = TAU - th;
}
const t = th / CONE_HALF;
const cone = t < 1 ? Math.pow(Math.cos(t * Math.PI / 2), CONE_POWER) : 0;
const fall = 1 / (1 + (d / LAMP_RANGE) * (d / LAMP_RANGE));
// 앞쪽 산란 — 빛이 눈을 지나 카메라 쪽으로 가는 정도. 카메라에 가까운 눈(z 큼)일수록, 원뿔 가운데와 바깥에서는 0 이고 반각의 절반 부근에서 가장 크다
const toward = clamp(f.z * 1.2 - 0.2, 0, 1) * Math.sin(Math.min(1, t) * Math.PI);
const cosA = clamp(toward, 0, 1);
const hg = (1 - PHASE_G * PHASE_G) / Math.pow(1 + PHASE_G * PHASE_G - 2 * PHASE_G * cosA, 1.5);
const scat = SCATTER * hg * 0.14;
return clamp(AMBIENT + cone * fall * (0.9 + scat), 0, 1.4);
}
07우산 위를 미끄러지는 눈
날씨 알림 서비스의 눈 오는 날 카드에 놓아 볼 만하다. 타원 우산 면의 법선을 구해 위에서 내려와 면을 지난 눈만 닿게 하고, 접선 속도의 0.6 배만 남겨 가장자리까지 미끄러뜨린다. 옆으로 들어온 눈은 우산 밑을 지나가 그 아래 바닥에는 그늘 타원이 진다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="우산 곡면의 법선 방향 속도를 감쇠하고 접선 방향만 남겨 눈이 우산 면을 따라 미끄러지다 가장자리에서 방울로 떨어지고, 우산 밑 바닥에는 그늘이 진다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 07 우산 위를 미끄러지는 눈 ───────────────────────────────
// 우산 윗면 타원(가운데 CX·CY, 가로 반지름 A, 세로 반지름 B)
const CX = 240;
const CY = 150;
const A = 104;
const B = 58;
// 우산 면을 따라 내리막으로 당기는 힘(px/초²)과 면 마찰(1/초)
const SLIDE_G = 430;
const FRICTION = 1.0;
// 바닥 눈 한 번 더하는 양과 높이 상한(px)
const GROUND_Y = 262;
const ADD = 0.7;
const CAP = 16;
const BIN_W = 6;
const BINS = W / BIN_W;
const FLAKES = 150;
const LOOP_S = 6;
const REST_STEPS = 60;
let flakes = [];
let ground = [];
let rng = seeded(707);
let slidingNow = 0;
let droppedTotal = 0;
function surfY(x) {
const u = (x - CX) / A;
return CY - B * Math.sqrt(Math.max(0, 1 - u * u));
}
// 우산 면 바깥쪽 법선 — 타원 식의 기울기 방향을 길이 1 로
function normalAt(x, y) {
const gx = (x - CX) / (A * A);
const gy = (y - CY) / (B * B);
const m = Math.hypot(gx, gy) || 1;
return [gx / m, gy / m];
}
function respawn(f, anywhere) {
f.x = rng() * (W + 40) - 20;
f.y = anywhere ? rng() * GROUND_Y : -6 - rng() * 20;
f.vx = 10 + rng() * 10;
f.vy = 70 + rng() * 40;
f.mode = 0;
f.speed = 0;
f.dir = 1;
f.s = 1.3 + rng() * 1.4;
}
function reset() {
rng = seeded(707);
flakes = [];
ground = [];
slidingNow = 0;
droppedTotal = 0;
for (let b = 0; b < BINS; b += 1) {
ground.push(0);
}
for (let i = 0; i < FLAKES; i += 1) {
const f = { x: 0, y: 0, vx: 0, vy: 0, mode: 0, speed: 0, dir: 1, s: 1 };
respawn(f, true);
flakes.push(f);
}
for (let k = 0; k < 60; k += 1) {
update(1 / 30);
}
}
function land(f) {
const b = clamp(Math.floor(f.x / BIN_W), 0, BINS - 1);
ground[b] = Math.min(CAP, ground[b] + ADD);
respawn(f, false);
}
function update(dt) {
const wind = userWind() * 30;
let sliding = 0;
for (const f of flakes) {
if (f.mode === 0) {
const prevY = f.y;
f.x += (f.vx + wind) * dt;
f.y += f.vy * dt;
if (Math.abs(f.x - CX) < A) {
const sy = surfY(f.x);
// 위에서 내려와 면을 지난 눈만 닿는다 — 옆으로 들어온 눈은 우산 밑을 지나간다
if (prevY <= sy && f.y >= sy) {
const n = normalAt(f.x, sy);
f.dir = f.x >= CX ? 1 : -1;
// 접선 — 내리막 쪽(아래로 향하는 쪽)으로 잡는다
const tx = f.dir * -n[1];
const ty = f.dir * n[0];
// 법선 성분은 없애고 접선 성분의 0.6 만 속도로 남긴다
f.speed = Math.max(0, (f.vx + wind) * tx + f.vy * ty) * 0.6;
f.y = sy;
f.mode = 1;
}
}
if (f.y >= GROUND_Y - ground[clamp(Math.floor(f.x / BIN_W), 0, BINS - 1)]) {
land(f);
}
}
else if (f.mode === 1) {
sliding += 1;
const n = normalAt(f.x, f.y);
const tx = f.dir * -n[1];
const ty = f.dir * n[0];
f.speed += (SLIDE_G * ty - FRICTION * f.speed) * dt;
if (f.speed < 8) {
f.speed = 8;
}
f.x += tx * f.speed * dt;
if (Math.abs(f.x - CX) >= A * 0.995) {
f.vx = tx * f.speed;
f.vy = Math.max(30, ty * f.speed);
f.mode = 2;
droppedTotal += 1;
}
else {
f.y = surfY(f.x);
}
}
else {
f.vy += 380 * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
if (f.y >= GROUND_Y - ground[clamp(Math.floor(f.x / BIN_W), 0, BINS - 1)]) {
land(f);
}
}
}
slidingNow = sliding;
}
function draw() {
skyFill([120, 124, 176], [200, 204, 232]);
// 바닥
box(0, GROUND_Y, W, H - GROUND_Y, 0, DUSK, 1);
// 우산 밑 그늘 — 바닥에 그늘 타원만 깐다(눈을 막지는 않는다)
ctx.beginPath();
ctx.ellipse(CX, GROUND_Y + 8, A * 0.95, 10, 0, 0, TAU);
ctx.fillStyle = rgba(DUSK_DEEP, 0.55);
ctx.fill();
// 바닥에 쌓인 눈
ctx.beginPath();
ctx.moveTo(0, GROUND_Y);
for (let b = 0; b < BINS; b += 1) {
ctx.lineTo(b * BIN_W, GROUND_Y - ground[b]);
ctx.lineTo((b + 1) * BIN_W, GROUND_Y - ground[b]);
}
ctx.lineTo(W, GROUND_Y);
ctx.closePath();
ctx.fillStyle = rgba(SNOW, 1);
ctx.fill();
// 손잡이
seg(CX, CY, CX, CY + 98, 4, INK, 1);
ctx.beginPath();
ctx.arc(CX - 10, CY + 98, 10, 0, Math.PI);
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
// 우산 살 — 일곱 개. 지붕 아래 가장자리는 물결
ctx.beginPath();
ctx.moveTo(CX - A, CY);
for (let k = 0; k <= 120; k += 1) {
ctx.lineTo(CX + A * Math.cos(Math.PI * (1 - k / 120)), CY - B * Math.sin(Math.PI * (1 - k / 120)));
}
for (let k = 7; k >= 0; k -= 1) {
ctx.quadraticCurveTo(CX - A + (k + 0.5) * (2 * A / 8), CY + 9, CX - A + k * (2 * A / 8), CY);
}
ctx.closePath();
ctx.fillStyle = rgba(ROSE, 1);
ctx.fill();
for (let k = 1; k < 8; k += 1) {
const ang = Math.PI * (1 - k / 8);
seg(CX, CY - B - 2 + 6, CX + A * Math.cos(ang), CY - B * Math.sin(ang), 1.5, [190, 122, 142], 0.8);
}
// 눈송이
for (const f of flakes) {
if (f.mode === 2) {
// 방울 — 아래로 길쭉한 물방울 모양
seg(f.x, f.y - Math.min(7, f.vy * 0.02), f.x, f.y, f.s * 1.2, WATER, 0.95);
}
else {
flakeDot(f.x, f.y - (f.mode === 1 ? 1.5 : 0), f.s, f.mode === 1 ? 0 : 0.8, SNOW, 0.95);
}
}
}
function statusText() {
return '미끄러지는 눈 {n}알 · 가장자리에서 떨어진 눈 {m}알'.replace('{n}', String(slidingNow)).replace('{m}', String(droppedTotal));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// UmbrellaDeflectSlide - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./UmbrellaDeflectSlide.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function UmbrellaDeflectSlide() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 07 우산 위를 미끄러지는 눈 ───────────────────────────────
// 우산 윗면 타원(가운데 CX·CY, 가로 반지름 A, 세로 반지름 B)
const CX = 240;
const CY = 150;
const A = 104;
const B = 58;
// 우산 면을 따라 내리막으로 당기는 힘(px/초²)과 면 마찰(1/초)
const SLIDE_G = 430;
const FRICTION = 1.0;
// 바닥 눈 한 번 더하는 양과 높이 상한(px)
const GROUND_Y = 262;
const ADD = 0.7;
const CAP = 16;
const BIN_W = 6;
const BINS = W / BIN_W;
const FLAKES = 150;
const LOOP_S = 6;
const REST_STEPS = 60;
// 눈 상태 — 0 떨어지는 중 · 1 우산 위를 미끄러지는 중 · 2 가장자리를 떠나 방울로 떨어지는 중
type Flake = { x: number; y: number; vx: number; vy: number; mode: number; speed: number; dir: number; s: number };
let flakes: Flake[] = [];
let ground: number[] = [];
let rng = seeded(707);
let slidingNow = 0;
let droppedTotal = 0;
function surfY(x: number): number {
const u = (x - CX) / A;
return CY - B * Math.sqrt(Math.max(0, 1 - u * u));
}
// 우산 면 바깥쪽 법선 — 타원 식의 기울기 방향을 길이 1 로
function normalAt(x: number, y: number): [number, number] {
const gx = (x - CX) / (A * A);
const gy = (y - CY) / (B * B);
const m = Math.hypot(gx, gy) || 1;
return [gx / m, gy / m];
}
function respawn(f: Flake, anywhere: boolean): void {
f.x = rng() * (W + 40) - 20;
f.y = anywhere ? rng() * GROUND_Y : -6 - rng() * 20;
f.vx = 10 + rng() * 10;
f.vy = 70 + rng() * 40;
f.mode = 0;
f.speed = 0;
f.dir = 1;
f.s = 1.3 + rng() * 1.4;
}
function reset(): void {
rng = seeded(707);
flakes = [];
ground = [];
slidingNow = 0;
droppedTotal = 0;
for (let b = 0; b < BINS; b += 1) { ground.push(0); }
for (let i = 0; i < FLAKES; i += 1) {
const f: Flake = { x: 0, y: 0, vx: 0, vy: 0, mode: 0, speed: 0, dir: 1, s: 1 };
respawn(f, true);
flakes.push(f);
}
for (let k = 0; k < 60; k += 1) { update(1 / 30); }
}
function land(f: Flake): void {
const b = clamp(Math.floor(f.x / BIN_W), 0, BINS - 1);
ground[b] = Math.min(CAP, ground[b] + ADD);
respawn(f, false);
}
function update(dt: number): void {
const wind = userWind() * 30;
let sliding = 0;
for (const f of flakes) {
if (f.mode === 0) {
const prevY = f.y;
f.x += (f.vx + wind) * dt;
f.y += f.vy * dt;
if (Math.abs(f.x - CX) < A) {
const sy = surfY(f.x);
// 위에서 내려와 면을 지난 눈만 닿는다 — 옆으로 들어온 눈은 우산 밑을 지나간다
if (prevY <= sy && f.y >= sy) {
const n = normalAt(f.x, sy);
f.dir = f.x >= CX ? 1 : -1;
// 접선 — 내리막 쪽(아래로 향하는 쪽)으로 잡는다
const tx = f.dir * -n[1];
const ty = f.dir * n[0];
// 법선 성분은 없애고 접선 성분의 0.6 만 속도로 남긴다
f.speed = Math.max(0, (f.vx + wind) * tx + f.vy * ty) * 0.6;
f.y = sy;
f.mode = 1;
}
}
if (f.y >= GROUND_Y - ground[clamp(Math.floor(f.x / BIN_W), 0, BINS - 1)]) { land(f); }
} else if (f.mode === 1) {
sliding += 1;
const n = normalAt(f.x, f.y);
const tx = f.dir * -n[1];
const ty = f.dir * n[0];
f.speed += (SLIDE_G * ty - FRICTION * f.speed) * dt;
if (f.speed < 8) { f.speed = 8; }
f.x += tx * f.speed * dt;
if (Math.abs(f.x - CX) >= A * 0.995) {
f.vx = tx * f.speed;
f.vy = Math.max(30, ty * f.speed);
f.mode = 2;
droppedTotal += 1;
} else {
f.y = surfY(f.x);
}
} else {
f.vy += 380 * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
if (f.y >= GROUND_Y - ground[clamp(Math.floor(f.x / BIN_W), 0, BINS - 1)]) { land(f); }
}
}
slidingNow = sliding;
}
function draw(): void {
skyFill([120, 124, 176], [200, 204, 232]);
// 바닥
box(0, GROUND_Y, W, H - GROUND_Y, 0, DUSK, 1);
// 우산 밑 그늘 — 바닥에 그늘 타원만 깐다(눈을 막지는 않는다)
ctx.beginPath();
ctx.ellipse(CX, GROUND_Y + 8, A * 0.95, 10, 0, 0, TAU);
ctx.fillStyle = rgba(DUSK_DEEP, 0.55);
ctx.fill();
// 바닥에 쌓인 눈
ctx.beginPath();
ctx.moveTo(0, GROUND_Y);
for (let b = 0; b < BINS; b += 1) {
ctx.lineTo(b * BIN_W, GROUND_Y - ground[b]);
ctx.lineTo((b + 1) * BIN_W, GROUND_Y - ground[b]);
}
ctx.lineTo(W, GROUND_Y);
ctx.closePath();
ctx.fillStyle = rgba(SNOW, 1);
ctx.fill();
// 손잡이
seg(CX, CY, CX, CY + 98, 4, INK, 1);
ctx.beginPath();
ctx.arc(CX - 10, CY + 98, 10, 0, Math.PI);
ctx.lineWidth = 4;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
// 우산 살 — 일곱 개. 지붕 아래 가장자리는 물결
ctx.beginPath();
ctx.moveTo(CX - A, CY);
for (let k = 0; k <= 120; k += 1) {
ctx.lineTo(CX + A * Math.cos(Math.PI * (1 - k / 120)), CY - B * Math.sin(Math.PI * (1 - k / 120)));
}
for (let k = 7; k >= 0; k -= 1) {
ctx.quadraticCurveTo(CX - A + (k + 0.5) * (2 * A / 8), CY + 9, CX - A + k * (2 * A / 8), CY);
}
ctx.closePath();
ctx.fillStyle = rgba(ROSE, 1);
ctx.fill();
for (let k = 1; k < 8; k += 1) {
const ang = Math.PI * (1 - k / 8);
seg(CX, CY - B - 2 + 6, CX + A * Math.cos(ang), CY - B * Math.sin(ang), 1.5, [190, 122, 142], 0.8);
}
// 눈송이
for (const f of flakes) {
if (f.mode === 2) {
// 방울 — 아래로 길쭉한 물방울 모양
seg(f.x, f.y - Math.min(7, f.vy * 0.02), f.x, f.y, f.s * 1.2, WATER, 0.95);
} else {
flakeDot(f.x, f.y - (f.mode === 1 ? 1.5 : 0), f.s, f.mode === 1 ? 0 : 0.8, SNOW, 0.95);
}
}
}
function statusText(): string {
return '미끄러지는 눈 {n}알 · 가장자리에서 떨어진 눈 {m}알'.replace('{n}', String(slidingNow)).replace('{m}', String(droppedTotal));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'우산 곡면의 법선 방향 속도를 감쇠하고 접선 방향만 남겨 눈이 우산 면을 따라 미끄러지다 가장자리에서 방울로 떨어지고, 우산 밑 바닥에는 그늘이 진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 우산 위를 미끄러지는 눈 — 측정 누적면적 23.972% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
if (Math.abs(f.x - CX) < A) {
const sy = surfY(f.x);
// 위에서 내려와 면을 지난 눈만 닿는다 — 옆으로 들어온 눈은 우산 밑을 지나간다
if (prevY <= sy && f.y >= sy) {
const n = normalAt(f.x, sy);
f.dir = f.x >= CX ? 1 : -1;
// 접선 — 내리막 쪽(아래로 향하는 쪽)으로 잡는다
const tx = f.dir * -n[1];
const ty = f.dir * n[0];
// 법선 성분은 없애고 접선 성분의 0.6 만 속도로 남긴다
f.speed = Math.max(0, (f.vx + wind) * tx + f.vy * ty) * 0.6;
f.y = sy;
f.mode = 1;
}
}
08줄무늬로 늘어나는 눈보라
겨울 스포츠 서비스 소개의 로딩 장면에 쓴다. 눈의 속도 방향으로 속도 × EXPOSURE 길이의 선분을 그어 눈을 줄무늬로 늘이고, 먼 눈은 하늘색에 섞어 시야를 줄인다. 돌풍 정점에서는 흰 막의 투명도가 0.34 까지 올라 화면이 하얗게 막힌다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="속도 방향으로 늘어난 줄무늬 눈보라가 돌풍에 따라 하얗게 시야를 막고, 숨었던 탭이 돌아온 긴 한 프레임을 그대로 받은 왼쪽은 눈이 한꺼번에 쏟아지고 시간 상한을 둔 오른쪽은 고르게 내린다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 08 줄무늬로 늘어나는 눈보라 ───────────────────────────────
// 눈이 지나간 자국 길이 = 속도 × 노출 시간(초)
const EXPOSURE = 0.06;
// 돌풍이 1 일 때 가로 바람 빠르기(px/초)와 기준 낙하 빠르기
const WIND = 300;
const FALL = 150;
// 먼 눈이 하늘색에 섞이는 최대 몫 — 시야가 줄어드는 정도
const FOG = 0.75;
// 눈이 생기는 빠르기(알/초)
const RATE = 190;
// 숨었던 탭이 돌아온 순간의 한 프레임 길이(초)와 그 시각표(초)
const HIDDEN_S = 1.3;
const HIDDEN_AT = [0.3, 1.6, 2.9, 4.2];
const LOOP_S = 5.4;
const REST_STEPS = 30;
const PANEL_W = 240;
let storms = [];
let hiddenSeen = 0;
function gustAt(t) { return 0.55 + 0.45 * Math.sin(t * 1.3); }
function makeStorm() { return { fl: [], carry: 0, rng: seeded(808) }; }
function spawn(s) {
const edge = s.rng() < 0.7;
s.fl.push({ x: edge ? s.rng() * (PANEL_W + 60) - 60 : -8 - s.rng() * 10, y: edge ? -6 - s.rng() * 8 : s.rng() * H, z: s.rng() });
}
function stepStorm(s, dt) {
const g = gustAt(sceneT) + userWind() * 0.5;
const next = [];
for (const f of s.fl) {
f.x += (WIND * g + 40) * (0.4 + f.z) * dt;
f.y += FALL * (0.3 + 0.9 * f.z) * dt;
if (f.x < PANEL_W + 20 && f.y < H + 10) {
next.push(f);
}
}
s.fl = next;
// 생기는 눈의 수는 시간에 비례한다 — 한 프레임이 길면 그만큼 한꺼번에 생긴다
s.carry += RATE * dt;
while (s.carry >= 1) {
s.carry -= 1;
spawn(s);
}
}
function reset() {
storms = [makeStorm(), makeStorm()];
hiddenSeen = 0;
for (let k = 0; k < 60; k += 1) {
for (const s of storms) {
stepStorm(s, 1 / 30);
}
}
}
function update(dt) {
if (hiddenSeen < HIDDEN_AT.length && sceneT >= HIDDEN_AT[hiddenSeen]) {
hiddenSeen += 1;
// 왼쪽은 긴 한 프레임을 그대로 받고, 오른쪽은 DT_MAX 로 자른 만큼만 받는다
stepStorm(storms[0], HIDDEN_S);
stepStorm(storms[1], Math.min(HIDDEN_S, DT_MAX));
return;
}
for (const s of storms) {
stepStorm(s, dt);
}
}
function drawStorm(s, ox) {
ctx.save();
ctx.beginPath();
ctx.rect(ox, 0, PANEL_W, H);
ctx.clip();
ctx.translate(ox, 0);
const g = gustAt(sceneT);
const sky = mix(SKY_TOP, SKY_BOT, 0.5);
for (const f of s.fl) {
const vx = (WIND * g + 40) * (0.4 + f.z);
const vy = FALL * (0.3 + 0.9 * f.z);
const len = EXPOSURE;
const c = mix(SNOW, sky, (1 - f.z) * FOG);
seg(f.x - vx * len, f.y - vy * len, f.x, f.y, 0.7 + f.z * 1.9, c, 0.3 + 0.65 * f.z);
}
// 하얗게 막힘 — 돌풍이 셀수록 짙은 막
ctx.fillStyle = rgba(SNOW, 0.34 * g * g);
ctx.fillRect(0, 0, PANEL_W, H);
ctx.restore();
}
function draw() {
skyFill(SKY_TOP, SKY_BOT);
// 바닥 선
box(0, 270, W, 30, 0, DUSK_DEEP, 1);
drawStorm(storms[0], 0);
drawStorm(storms[1], PANEL_W);
box(PANEL_W - 1, 0, 2, H, 0, INK, 0.55);
tag('한 프레임 {d}초 그대로'.replace('{d}', String(HIDDEN_S)), PANEL_W / 2, 284, PAPER, INK, 10, 'center');
tag('상한 {d}초로 자름'.replace('{d}', String(DT_MAX)), PANEL_W * 1.5, 284, PAPER, INK, 10, 'center');
}
function statusText() {
return '그대로 {a}알 · 자른 쪽 {b}알'.replace('{a}', String(storms[0].fl.length)).replace('{b}', String(storms[1].fl.length));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// BlizzardStreakWhiteout - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./BlizzardStreakWhiteout.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BlizzardStreakWhiteout() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 08 줄무늬로 늘어나는 눈보라 ───────────────────────────────
// 눈이 지나간 자국 길이 = 속도 × 노출 시간(초)
const EXPOSURE = 0.06;
// 돌풍이 1 일 때 가로 바람 빠르기(px/초)와 기준 낙하 빠르기
const WIND = 300;
const FALL = 150;
// 먼 눈이 하늘색에 섞이는 최대 몫 — 시야가 줄어드는 정도
const FOG = 0.75;
// 눈이 생기는 빠르기(알/초)
const RATE = 190;
// 숨었던 탭이 돌아온 순간의 한 프레임 길이(초)와 그 시각표(초)
const HIDDEN_S = 1.3;
const HIDDEN_AT: number[] = [0.3, 1.6, 2.9, 4.2];
const LOOP_S = 5.4;
const REST_STEPS = 30;
const PANEL_W = 240;
type Flake = { x: number; y: number; z: number };
type Storm = { fl: Flake[]; carry: number; rng: () => number };
let storms: Storm[] = [];
let hiddenSeen = 0;
function gustAt(t: number): number { return 0.55 + 0.45 * Math.sin(t * 1.3); }
function makeStorm(): Storm { return { fl: [], carry: 0, rng: seeded(808) }; }
function spawn(s: Storm): void {
const edge = s.rng() < 0.7;
s.fl.push({ x: edge ? s.rng() * (PANEL_W + 60) - 60 : -8 - s.rng() * 10, y: edge ? -6 - s.rng() * 8 : s.rng() * H, z: s.rng() });
}
function stepStorm(s: Storm, dt: number): void {
const g = gustAt(sceneT) + userWind() * 0.5;
const next: Flake[] = [];
for (const f of s.fl) {
f.x += (WIND * g + 40) * (0.4 + f.z) * dt;
f.y += FALL * (0.3 + 0.9 * f.z) * dt;
if (f.x < PANEL_W + 20 && f.y < H + 10) { next.push(f); }
}
s.fl = next;
// 생기는 눈의 수는 시간에 비례한다 — 한 프레임이 길면 그만큼 한꺼번에 생긴다
s.carry += RATE * dt;
while (s.carry >= 1) { s.carry -= 1; spawn(s); }
}
function reset(): void {
storms = [makeStorm(), makeStorm()];
hiddenSeen = 0;
for (let k = 0; k < 60; k += 1) { for (const s of storms) { stepStorm(s, 1 / 30); } }
}
function update(dt: number): void {
if (hiddenSeen < HIDDEN_AT.length && sceneT >= HIDDEN_AT[hiddenSeen]) {
hiddenSeen += 1;
// 왼쪽은 긴 한 프레임을 그대로 받고, 오른쪽은 DT_MAX 로 자른 만큼만 받는다
stepStorm(storms[0], HIDDEN_S);
stepStorm(storms[1], Math.min(HIDDEN_S, DT_MAX));
return;
}
for (const s of storms) { stepStorm(s, dt); }
}
function drawStorm(s: Storm, ox: number): void {
ctx.save();
ctx.beginPath();
ctx.rect(ox, 0, PANEL_W, H);
ctx.clip();
ctx.translate(ox, 0);
const g = gustAt(sceneT);
const sky = mix(SKY_TOP, SKY_BOT, 0.5);
for (const f of s.fl) {
const vx = (WIND * g + 40) * (0.4 + f.z);
const vy = FALL * (0.3 + 0.9 * f.z);
const len = EXPOSURE;
const c = mix(SNOW, sky, (1 - f.z) * FOG);
seg(f.x - vx * len, f.y - vy * len, f.x, f.y, 0.7 + f.z * 1.9, c, 0.3 + 0.65 * f.z);
}
// 하얗게 막힘 — 돌풍이 셀수록 짙은 막
ctx.fillStyle = rgba(SNOW, 0.34 * g * g);
ctx.fillRect(0, 0, PANEL_W, H);
ctx.restore();
}
function draw(): void {
skyFill(SKY_TOP, SKY_BOT);
// 바닥 선
box(0, 270, W, 30, 0, DUSK_DEEP, 1);
drawStorm(storms[0], 0);
drawStorm(storms[1], PANEL_W);
box(PANEL_W - 1, 0, 2, H, 0, INK, 0.55);
tag('한 프레임 {d}초 그대로'.replace('{d}', String(HIDDEN_S)), PANEL_W / 2, 284, PAPER, INK, 10, 'center');
tag('상한 {d}초로 자름'.replace('{d}', String(DT_MAX)), PANEL_W * 1.5, 284, PAPER, INK, 10, 'center');
}
function statusText(): string {
return '그대로 {a}알 · 자른 쪽 {b}알'.replace('{a}', String(storms[0].fl.length)).replace('{b}', String(storms[1].fl.length));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'속도 방향으로 늘어난 줄무늬 눈보라가 돌풍에 따라 하얗게 시야를 막고, 숨었던 탭이 돌아온 긴 한 프레임을 그대로 받은 왼쪽은 눈이 한꺼번에 쏟아지고 시간 상한을 둔 오른쪽은 고르게 내린다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 줄무늬로 늘어나는 눈보라 — 측정 누적면적 70.178% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function drawStorm(s, ox) {
ctx.save();
ctx.beginPath();
ctx.rect(ox, 0, PANEL_W, H);
ctx.clip();
ctx.translate(ox, 0);
const g = gustAt(sceneT);
const sky = mix(SKY_TOP, SKY_BOT, 0.5);
for (const f of s.fl) {
const vx = (WIND * g + 40) * (0.4 + f.z);
const vy = FALL * (0.3 + 0.9 * f.z);
const len = EXPOSURE;
const c = mix(SNOW, sky, (1 - f.z) * FOG);
seg(f.x - vx * len, f.y - vy * len, f.x, f.y, 0.7 + f.z * 1.9, c, 0.3 + 0.65 * f.z);
}
// 하얗게 막힘 — 돌풍이 셀수록 짙은 막
ctx.fillStyle = rgba(SNOW, 0.34 * g * g);
ctx.fillRect(0, 0, PANEL_W, H);
ctx.restore();
}
09눈 내리는 밤 지붕 히어로 장면
연말 캠페인 사이트의 히어로로 쓴다. 지붕 모양 높이 지도에 가까운 눈(z 0.45 이상)만 쌓이고, 쌓임은 한 알을 세 열에 0.5·0.25·0.25 로 나눠 더해 지붕 선이 부드럽게 두꺼워진다. 가로등 원뿔 안의 눈만 밝고 먼 눈은 집 뒤를 지나 땅에서 위로 돌아간다.

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="눈 내리는 밤 불 켜진 집 지붕과 땅에 높이 지도로 눈이 쌓여 지붕 선이 하얗게 두꺼워지고, 가로등 빛 원뿔 안의 눈만 반짝이며 깊이별 눈송이 층이 겹친다"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">눈 내리는 장면</p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 무대 상자 — 여기서 시작한 터치만 바람 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: grab;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed) {
let s = (seed * 2654435761) >>> 0 || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind() { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW = [255, 255, 255];
const SNOW_SHADE = [208, 222, 246];
const SKY_TOP = [70, 82, 140];
const SKY_BOT = [146, 158, 212];
const NIGHT_TOP = [38, 44, 86];
const NIGHT_BOT = [78, 88, 140];
const GROUND = [196, 206, 232];
const DUSK = [96, 104, 156];
const DUSK_DEEP = [60, 66, 112];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const LAMP = [255, 232, 168];
const BUTTER = [247, 226, 156];
const ROSE = [232, 164, 178];
const MINT = [166, 220, 200];
const WATER = [190, 222, 248];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x, y, r, blur, c, a) {
if (blur > 0.3) {
disc(x, y, r + blur, c, a * 0.3);
}
disc(x, y, r, c, a);
}
// ── 09 눈 내리는 밤 지붕 히어로 장면 ─────────────────────────
// 기울기 한계(열 사이 높이 차, 알 단위)와 눈 한 알이 더하는 높이(px). 알 하나는 이웃 열에도 나눠 준다
const REPOSE = 1;
const ADD = 4;
const GRAIN = 3;
const COLS = W / GRAIN;
const GROUND_Y = 262;
const FLAKES = 230;
const LOOP_S = 7;
const REST_STEPS = 90;
// 지붕 모양 — 꼭대기와 기울기(열 하나당 세로 변화 ÷ 가로)
const ROOF_X0 = 100;
const ROOF_X1 = 380;
const ROOF_TOP = 112;
const ROOF_SLOPE = 0.5;
const CHIMNEY_X0 = 300;
const CHIMNEY_X1 = 318;
const CHIMNEY_Y = 80;
// 가로등 — 빛 원뿔
const LAMP_X = 56;
const LAMP_Y = 120;
const CONE_HALF = 0.55;
const LAMP_RANGE = 200;
const AMBIENT = 0.2;
let flakes = [];
let h = [];
const baseY = [];
let rng = seeded(909);
let litCount = 0;
for (let c = 0; c < COLS; c += 1) {
const x = c * GRAIN + GRAIN / 2;
let y = GROUND_Y;
if (x >= ROOF_X0 && x <= ROOF_X1) {
y = ROOF_TOP + Math.abs(x - 240) * ROOF_SLOPE;
}
if (x >= CHIMNEY_X0 && x <= CHIMNEY_X1) {
y = CHIMNEY_Y;
}
baseY.push(y);
}
function topOf(c) { return baseY[c] - h[c]; }
function reset() {
rng = seeded(909);
flakes = [];
h = [];
for (let c = 0; c < COLS; c += 1) {
h.push(0);
}
for (let i = 0; i < FLAKES; i += 1) {
const s = 1.1 + Math.pow(rng(), 1.7) * 3.6;
flakes.push({ x: rng() * (W + 40) - 20, y: rng() * GROUND_Y, z: (s - 1.1) / 3.6, s: s, ph: rng() * TAU, lit: 0 });
}
for (let k = 0; k < 30; k += 1) {
update(1 / 30);
}
}
// 기울기 한계 — 이웃보다 한계 넘게 높으면 넘은 만큼의 반을 낮은 쪽으로. 굴뚝처럼 바닥 높이가 갑자기 달라지는 곳은 벽이라 흘리지 않는다
function relax() {
const maxDiff = REPOSE * GRAIN;
for (let pass = 0; pass < 3; pass += 1) {
for (let c = 0; c < COLS; c += 1) {
for (let d = -1; d <= 1; d += 2) {
const n = c + d;
if (n < 0 || n >= COLS || Math.abs(baseY[c] - baseY[n]) > 6) {
continue;
}
const excess = topOf(n) - topOf(c) - maxDiff;
if (excess > 0 && h[c] > 0) {
const move = Math.min(h[c], excess * 0.5);
h[c] -= move;
h[n] += move;
}
}
}
}
}
function update(dt) {
const wind = autoOn ? Math.sin(sceneT * 0.9) * 0.4 : userWind();
for (const f of flakes) {
f.y += (28 + 62 * f.z) * dt;
f.x += wind * 26 * (0.3 + f.z) * dt + Math.sin(sceneT * 1.5 + f.ph) * 7 * dt;
if (f.x > W + 20) {
f.x -= W + 40;
}
if (f.x < -20) {
f.x += W + 40;
}
// 가까운 눈(z ≥ 0.45)만 지붕과 땅에 닿아 쌓인다. 먼 눈은 집 뒤를 지나 바닥에서 위로 돌아간다
if (f.z >= 0.45) {
const c = clamp(Math.floor(f.x / GRAIN), 0, COLS - 1);
if (f.y >= topOf(c)) {
h[c] += ADD * 0.5;
if (c > 0) {
h[c - 1] += ADD * 0.25;
}
if (c < COLS - 1) {
h[c + 1] += ADD * 0.25;
}
f.y = -4 - rng() * 20;
f.x = rng() * (W + 40) - 20;
}
}
else if (f.y > GROUND_Y) {
f.y = -4 - rng() * 20;
}
}
relax();
}
// 불빛 속 눈의 밝기 — 원뿔 몫 × 거리 감쇠, 원뿔 밖은 AMBIENT
function lampLight(x, y) {
const axis = 0.18 + Math.sin(sceneT * 0.9) * 0.12;
const dx = x - LAMP_X;
const dy = y - LAMP_Y;
const d = Math.hypot(dx, dy);
const th = Math.abs(Math.atan2(dy, dx) - axis);
const t = th / CONE_HALF;
const cone = t < 1 ? Math.pow(Math.cos(t * Math.PI / 2), 2) : 0;
return clamp(AMBIENT + cone / (1 + (d / LAMP_RANGE) * (d / LAMP_RANGE)), 0, 1);
}
function drawFlakes(far) {
let lit = 0;
for (const f of flakes) {
if ((f.z < 0.45) !== far) {
continue;
}
const b = lampLight(f.x, f.y);
if (b > 0.5) {
lit += 1;
}
const tw = b > 0.5 ? 0.8 + 0.2 * Math.sin(sceneT * 9 + f.ph) : 1;
flakeDot(f.x, f.y, f.s * 0.6, (1 - f.z) * 1.4 + (b > 0.6 ? 1.2 : 0), mix(SNOW_SHADE, SNOW, b), clamp(0.3 + 0.7 * b, 0, 1) * tw * lerp(0.6, 1, f.z));
}
return lit;
}
function draw() {
skyFill(NIGHT_TOP, NIGHT_BOT);
// 땅
box(0, GROUND_Y, W, H - GROUND_Y, 0, DUSK_DEEP, 1);
let lit = drawFlakes(true);
// 집 — 벽 · 굴뚝 · 지붕 · 불 켜진 창
box(116, 170, 248, GROUND_Y - 170, 0, DUSK, 1);
box(CHIMNEY_X0 - 2, CHIMNEY_Y, CHIMNEY_X1 - CHIMNEY_X0 + 4, 80, 0, DUSK_DEEP, 1);
ctx.beginPath();
ctx.moveTo(ROOF_X0 - 6, ROOF_TOP + (240 - ROOF_X0 + 6) * ROOF_SLOPE + 4);
ctx.lineTo(240, ROOF_TOP - 3);
ctx.lineTo(ROOF_X1 + 6, ROOF_TOP + (ROOF_X1 + 6 - 240) * ROOF_SLOPE + 4);
ctx.closePath();
ctx.fillStyle = rgba(DUSK_DEEP, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
box(146 + i * 74, 196, 34, 38, 4, BUTTER, 1);
box(146 + i * 74 + 16, 196, 2, 38, 0, DUSK_DEEP, 0.7);
}
// 쌓인 눈 — 지붕 선과 땅 위 높이 지도
for (let c = 0; c < COLS; c += 1) {
if (h[c] > 0) {
box(c * GRAIN, topOf(c), GRAIN + 0.6, h[c], 0, SNOW, 1);
}
}
// 가로등과 원뿔
ctx.beginPath();
ctx.moveTo(LAMP_X, LAMP_Y);
const axis = 0.18 + Math.sin(sceneT * 0.9) * 0.12;
ctx.arc(LAMP_X, LAMP_Y, 300, axis - CONE_HALF, axis + CONE_HALF);
ctx.closePath();
const g = ctx.createRadialGradient(LAMP_X, LAMP_Y, 6, LAMP_X, LAMP_Y, 260);
g.addColorStop(0, rgba(LAMP, 0.42));
g.addColorStop(1, rgba(LAMP, 0));
ctx.fillStyle = g;
ctx.fill();
box(LAMP_X - 3, LAMP_Y, 6, GROUND_Y - LAMP_Y, 2, INK, 1);
box(LAMP_X - 11, LAMP_Y - 12, 22, 14, 6, INK, 1);
disc(LAMP_X, LAMP_Y + 2, 7, LAMP, 1);
lit += drawFlakes(false);
litCount = lit;
}
function statusText() {
let top = 0;
for (let c = 0; c < COLS; c += 1) {
const x = c * GRAIN + GRAIN / 2;
if (x >= ROOF_X0 && x <= ROOF_X1 && !(x >= CHIMNEY_X0 && x <= CHIMNEY_X1)) {
top = Math.max(top, h[c]);
}
}
return '지붕 눈 두께 {t}px · 불빛 속 눈 {n}개'.replace('{t}', String(Math.round(top))).replace('{n}', String(litCount));
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver() {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow() {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) {
update(1 / 30);
}
draw();
pushStatus(statusText());
}
function play() {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
}
else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e) {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (dragging !== e.pointerId) {
return;
}
windAt(e);
showNow();
}, { signal: off.signal });
function release(e) {
if (dragging !== e.pointerId) {
return;
}
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HeroRoofNight - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HeroRoofNight.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroRoofNight() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 무대 좌표 — 캔버스 안 계산은 늘 480×300. 화면에 보이는 크기와 상관없다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 같은 시드면 같은 눈 (32비트 xorshift 에 곱셈 섞기 한 번)
function seeded(seed: number): () => number {
let s = (seed * 2654435761) >>> 0 || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return (Math.imul(s, 0x9e3779b1) >>> 0) / 4294967296;
};
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 자동 시연이 장면을 움직이는가 — 사람이 무대에서 처음 손대면 꺼진다
let autoOn = true;
// 사람이 주는 바람(−1 왼쪽 ~ 1 오른쪽) — 방향키·무대 위 가로 끌기. 자동 시연 중에는 0
let windUser = 0;
function userWind(): number { return autoOn ? 0 : windUser; }
// 파스텔 팔레트 — 눈은 흰색 계열(기능색), 하늘·땅은 낮은 채도의 해질녘 청보라
const SNOW: RGB = [255, 255, 255];
const SNOW_SHADE: RGB = [208, 222, 246];
const SKY_TOP: RGB = [70, 82, 140];
const SKY_BOT: RGB = [146, 158, 212];
const NIGHT_TOP: RGB = [38, 44, 86];
const NIGHT_BOT: RGB = [78, 88, 140];
const GROUND: RGB = [196, 206, 232];
const DUSK: RGB = [96, 104, 156];
const DUSK_DEEP: RGB = [60, 66, 112];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const LAMP: RGB = [255, 232, 168];
const BUTTER: RGB = [247, 226, 156];
const ROSE: RGB = [232, 164, 178];
const MINT: RGB = [166, 220, 200];
const WATER: RGB = [190, 222, 248];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function box(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 바탕 알약 위에 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 눈송이 한 알 — 번짐 반지름(blur)만큼 옅은 후광을 먼저 깔고 가운데를 또렷하게
function flakeDot(x: number, y: number, r: number, blur: number, c: RGB, a: number): void {
if (blur > 0.3) { disc(x, y, r + blur, c, a * 0.3); }
disc(x, y, r, c, a);
}
// ── 09 눈 내리는 밤 지붕 히어로 장면 ─────────────────────────
// 기울기 한계(열 사이 높이 차, 알 단위)와 눈 한 알이 더하는 높이(px). 알 하나는 이웃 열에도 나눠 준다
const REPOSE = 1;
const ADD = 4;
const GRAIN = 3;
const COLS = W / GRAIN;
const GROUND_Y = 262;
const FLAKES = 230;
const LOOP_S = 7;
const REST_STEPS = 90;
// 지붕 모양 — 꼭대기와 기울기(열 하나당 세로 변화 ÷ 가로)
const ROOF_X0 = 100;
const ROOF_X1 = 380;
const ROOF_TOP = 112;
const ROOF_SLOPE = 0.5;
const CHIMNEY_X0 = 300;
const CHIMNEY_X1 = 318;
const CHIMNEY_Y = 80;
// 가로등 — 빛 원뿔
const LAMP_X = 56;
const LAMP_Y = 120;
const CONE_HALF = 0.55;
const LAMP_RANGE = 200;
const AMBIENT = 0.2;
type Flake = { x: number; y: number; z: number; s: number; ph: number; lit: number };
let flakes: Flake[] = [];
let h: number[] = [];
const baseY: number[] = [];
let rng = seeded(909);
let litCount = 0;
for (let c = 0; c < COLS; c += 1) {
const x = c * GRAIN + GRAIN / 2;
let y = GROUND_Y;
if (x >= ROOF_X0 && x <= ROOF_X1) { y = ROOF_TOP + Math.abs(x - 240) * ROOF_SLOPE; }
if (x >= CHIMNEY_X0 && x <= CHIMNEY_X1) { y = CHIMNEY_Y; }
baseY.push(y);
}
function topOf(c: number): number { return baseY[c] - h[c]; }
function reset(): void {
rng = seeded(909);
flakes = [];
h = [];
for (let c = 0; c < COLS; c += 1) { h.push(0); }
for (let i = 0; i < FLAKES; i += 1) {
const s = 1.1 + Math.pow(rng(), 1.7) * 3.6;
flakes.push({ x: rng() * (W + 40) - 20, y: rng() * GROUND_Y, z: (s - 1.1) / 3.6, s: s, ph: rng() * TAU, lit: 0 });
}
for (let k = 0; k < 30; k += 1) { update(1 / 30); }
}
// 기울기 한계 — 이웃보다 한계 넘게 높으면 넘은 만큼의 반을 낮은 쪽으로. 굴뚝처럼 바닥 높이가 갑자기 달라지는 곳은 벽이라 흘리지 않는다
function relax(): void {
const maxDiff = REPOSE * GRAIN;
for (let pass = 0; pass < 3; pass += 1) {
for (let c = 0; c < COLS; c += 1) {
for (let d = -1; d <= 1; d += 2) {
const n = c + d;
if (n < 0 || n >= COLS || Math.abs(baseY[c] - baseY[n]) > 6) { continue; }
const excess = topOf(n) - topOf(c) - maxDiff;
if (excess > 0 && h[c] > 0) {
const move = Math.min(h[c], excess * 0.5);
h[c] -= move;
h[n] += move;
}
}
}
}
}
function update(dt: number): void {
const wind = autoOn ? Math.sin(sceneT * 0.9) * 0.4 : userWind();
for (const f of flakes) {
f.y += (28 + 62 * f.z) * dt;
f.x += wind * 26 * (0.3 + f.z) * dt + Math.sin(sceneT * 1.5 + f.ph) * 7 * dt;
if (f.x > W + 20) { f.x -= W + 40; }
if (f.x < -20) { f.x += W + 40; }
// 가까운 눈(z ≥ 0.45)만 지붕과 땅에 닿아 쌓인다. 먼 눈은 집 뒤를 지나 바닥에서 위로 돌아간다
if (f.z >= 0.45) {
const c = clamp(Math.floor(f.x / GRAIN), 0, COLS - 1);
if (f.y >= topOf(c)) {
h[c] += ADD * 0.5;
if (c > 0) { h[c - 1] += ADD * 0.25; }
if (c < COLS - 1) { h[c + 1] += ADD * 0.25; }
f.y = -4 - rng() * 20;
f.x = rng() * (W + 40) - 20;
}
} else if (f.y > GROUND_Y) {
f.y = -4 - rng() * 20;
}
}
relax();
}
// 불빛 속 눈의 밝기 — 원뿔 몫 × 거리 감쇠, 원뿔 밖은 AMBIENT
function lampLight(x: number, y: number): number {
const axis = 0.18 + Math.sin(sceneT * 0.9) * 0.12;
const dx = x - LAMP_X;
const dy = y - LAMP_Y;
const d = Math.hypot(dx, dy);
const th = Math.abs(Math.atan2(dy, dx) - axis);
const t = th / CONE_HALF;
const cone = t < 1 ? Math.pow(Math.cos(t * Math.PI / 2), 2) : 0;
return clamp(AMBIENT + cone / (1 + (d / LAMP_RANGE) * (d / LAMP_RANGE)), 0, 1);
}
function drawFlakes(far: boolean): number {
let lit = 0;
for (const f of flakes) {
if ((f.z < 0.45) !== far) { continue; }
const b = lampLight(f.x, f.y);
if (b > 0.5) { lit += 1; }
const tw = b > 0.5 ? 0.8 + 0.2 * Math.sin(sceneT * 9 + f.ph) : 1;
flakeDot(f.x, f.y, f.s * 0.6, (1 - f.z) * 1.4 + (b > 0.6 ? 1.2 : 0), mix(SNOW_SHADE, SNOW, b), clamp(0.3 + 0.7 * b, 0, 1) * tw * lerp(0.6, 1, f.z));
}
return lit;
}
function draw(): void {
skyFill(NIGHT_TOP, NIGHT_BOT);
// 땅
box(0, GROUND_Y, W, H - GROUND_Y, 0, DUSK_DEEP, 1);
let lit = drawFlakes(true);
// 집 — 벽 · 굴뚝 · 지붕 · 불 켜진 창
box(116, 170, 248, GROUND_Y - 170, 0, DUSK, 1);
box(CHIMNEY_X0 - 2, CHIMNEY_Y, CHIMNEY_X1 - CHIMNEY_X0 + 4, 80, 0, DUSK_DEEP, 1);
ctx.beginPath();
ctx.moveTo(ROOF_X0 - 6, ROOF_TOP + (240 - ROOF_X0 + 6) * ROOF_SLOPE + 4);
ctx.lineTo(240, ROOF_TOP - 3);
ctx.lineTo(ROOF_X1 + 6, ROOF_TOP + (ROOF_X1 + 6 - 240) * ROOF_SLOPE + 4);
ctx.closePath();
ctx.fillStyle = rgba(DUSK_DEEP, 1);
ctx.fill();
for (let i = 0; i < 3; i += 1) {
box(146 + i * 74, 196, 34, 38, 4, BUTTER, 1);
box(146 + i * 74 + 16, 196, 2, 38, 0, DUSK_DEEP, 0.7);
}
// 쌓인 눈 — 지붕 선과 땅 위 높이 지도
for (let c = 0; c < COLS; c += 1) {
if (h[c] > 0) { box(c * GRAIN, topOf(c), GRAIN + 0.6, h[c], 0, SNOW, 1); }
}
// 가로등과 원뿔
ctx.beginPath();
ctx.moveTo(LAMP_X, LAMP_Y);
const axis = 0.18 + Math.sin(sceneT * 0.9) * 0.12;
ctx.arc(LAMP_X, LAMP_Y, 300, axis - CONE_HALF, axis + CONE_HALF);
ctx.closePath();
const g = ctx.createRadialGradient(LAMP_X, LAMP_Y, 6, LAMP_X, LAMP_Y, 260);
g.addColorStop(0, rgba(LAMP, 0.42));
g.addColorStop(1, rgba(LAMP, 0));
ctx.fillStyle = g;
ctx.fill();
box(LAMP_X - 3, LAMP_Y, 6, GROUND_Y - LAMP_Y, 2, INK, 1);
box(LAMP_X - 11, LAMP_Y - 12, 22, 14, 6, INK, 1);
disc(LAMP_X, LAMP_Y + 2, 7, LAMP, 1);
lit += drawFlakes(false);
litCount = lit;
}
function statusText(): string {
let top = 0;
for (let c = 0; c < COLS; c += 1) {
const x = c * GRAIN + GRAIN / 2;
if (x >= ROOF_X0 && x <= ROOF_X1 && !(x >= CHIMNEY_X0 && x <= CHIMNEY_X1)) { top = Math.max(top, h[c]); }
}
return '지붕 눈 두께 {t}px · 불빛 속 눈 {n}개'.replace('{t}', String(Math.round(top))).replace('{n}', String(litCount));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 바람을 만지면 자동 시연의 바람은 손을 뗀다
function takeOver(): void {
wake();
autoOn = false;
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 방향키 한 번에 바뀌는 바람 몫
const WIND_STEP = 0.25;
function showNow(): void {
if (still || paused) {
draw();
pushStatus(statusText());
}
}
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus(statusText());
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 루프 없이 눈이 이미 내려 쌓인 정지 장면 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < REST_STEPS; k += 1) { update(1 / 30); }
draw();
pushStatus(statusText());
}
function play(): void {
sceneT = 0;
reset();
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 방향키 — 무대에 포커스가 있을 때만. 왼쪽·오른쪽은 바람, Enter·Space 는 일시정지. 무대 밖에서는 화살표가 페이지를 스크롤한다
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
e.preventDefault();
takeOver();
windUser = clamp(windUser + (e.key === 'ArrowRight' ? WIND_STEP : -WIND_STEP), -1, 1);
showNow();
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
togglePause();
}
}, { signal: off.signal });
// 포인터 — 무대 안에서 누른 채 가로로 끌면 그 위치가 바람(가운데 0, 가장자리 ±1)
let dragging = -1;
function windAt(e: PointerEvent): void {
const r = pad.getBoundingClientRect();
windUser = clamp(((e.clientX - r.left) / r.width - 0.5) * 2.4, -1, 1);
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pad.setPointerCapture(e.pointerId);
dragging = e.pointerId;
windAt(e);
showNow();
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
windAt(e);
showNow();
}, { signal: off.signal });
function release(e: PointerEvent): void {
if (dragging !== e.pointerId) { return; }
dragging = -1;
windUser = 0;
showNow();
}
pad.addEventListener('pointerup', release, { signal: off.signal });
pad.addEventListener('pointercancel', release, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'눈 장면 무대 — 누른 뒤 왼쪽·오른쪽 방향키로 바람 바꾸기, Enter 로 일시정지'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'눈 내리는 밤 불 켜진 집 지붕과 땅에 높이 지도로 눈이 쌓여 지붕 선이 하얗게 두꺼워지고, 가로등 빛 원뿔 안의 눈만 반짝이며 깊이별 눈송이 층이 겹친다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'눈 내리는 장면'}</p>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 눈 내리는 밤 지붕 히어로 장면 — 측정 누적면적 27.677% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 가까운 눈(z ≥ 0.45)만 지붕과 땅에 닿아 쌓인다. 먼 눈은 집 뒤를 지나 바닥에서 위로 돌아간다
if (f.z >= 0.45) {
const c = clamp(Math.floor(f.x / GRAIN), 0, COLS - 1);
if (f.y >= topOf(c)) {
h[c] += ADD * 0.5;
if (c > 0) {
h[c - 1] += ADD * 0.25;
}
if (c < COLS - 1) {
h[c + 1] += ADD * 0.25;
}
f.y = -4 - rng() * 20;
f.x = rng() * (W + 40) - 20;
}
}
눈 쌓이는 장면 만들기는 어디에 눈을 얼마나 더해야 자연스러울까?
눈은 어디에나 같은 양으로 쌓이지 않는다. 03 은 눈의 65% 를 세 줄기 열(17·40·64) 근처로 떨어뜨리고 나머지를 화면 전체에 흩뿌려, 줄기 아래는 두껍고 사이는 얇은 눈 쌓이는 장면을 만든다.
글자 선반 위에 쌓을 때
글자 윗면처럼 높이가 다른 선반을 BASE 로 두면 눈은 선반 위 표면(선반 + 쌓인 눈)에서 멈춘다. 03 의 선반은 18px·36px·12px 의 세 덩어리이고, 눈은 각 덩어리 윗면에서 쌓이기 시작한다. 연말 인사 페이지에서는 이 선반을 제목 글자 윗면 높이로 바꿔 쓰면 된다.
지붕에 쌓을 때
09 는 가까운 눈만 쌓이게 하고 먼 눈은 집 뒤를 지나 땅에서 위로 돌려보낸다. 굴뚝처럼 바닥 높이가 6px 넘게 갑자기 달라지는 곳은 벽으로 보고 흘려 보내지 않으므로 눈이 굴뚝 옆에 쌓여 선이 꺾인다. 쌓임 속도를 ADD 4 로 잡았을 때 7초 시연 끝에 지붕 선이 두꺼워진다.
유리·우산처럼 쌓이지 않는 면에서는
04 는 눈이 쌓이는 대신 녹은 물기로 남고, 07 은 우산 면에서 미끄러진 눈이 땅의 ground 열에 쌓이는 식으로 눈의 양이 드러난다. 같은 눈이라도 닿는 면의 성질이 다르면 남는 것이 달라진다는 점이 눈 장면의 설득력을 만든다.
높이 지도로 눈을 쌓고, 기울기를 넘으면 옆으로 흘려보내고, 바람으로 흔든다 — 눈 한 알이 눈더미가 되는 순서
눈 한 알이 눈더미가 되는 길은 네 단계다. 바닥에 닿는 것을 지우지 않고 높이 지도에 더하고, 한계를 넘는 차이를 옆으로 옮기고, 바람으로 흔들고, 먼 것부터 그린다.
| 단계 | 하는 일 | 쓰는 이름 | 쓰는 장면 |
|---|---|---|---|
| 1 더하기 | 닿은 눈을 열 높이에 ADD 만큼 더하고 눈은 위에서 다시 내려보냄 |
surface·stepPanel |
03·07·09 |
| 2 흘리기 | 이웃보다 한계 넘게 높으면 넘은 만큼의 절반을 낮은 쪽으로 | relax |
03·09 |
| 3 흔들기 | 사인 합의 장 또는 방향키 값을 눈 속도에 더함 | fieldAt·WIND_STEP |
01·02·05·08 |
| 4 그리기 | 크기 순으로 정렬해 먼 눈부터 그림 | flakes.sort |
01·02 |
한계를 넘는 차이만 옮기는 이유
03 은 허용하는 높이 차를 REPOSE × GRAIN 으로 둔다. 1 × 3 이므로 이웃 열보다 3px 넘게 높은 열만 무너지고, 열 폭이 3px 이니 기울기는 45도다. 한계 이하의 차이는 건드리지 않아 눈더미가 완만한 언덕 모양으로 굳는다.
한 번에 옮기지 않고 네 번 되풀이하는 이유
한 열이 무너져 낮은 이웃을 올리면 그 이웃이 다시 한계를 넘을 수 있다. 03 은 한 걸음에 relax 안의 반복을 4번, 09 는 3번 돌려 무너짐이 몇 열 건너까지 번지게 한다. 반복을 늘릴수록 눈더미는 부드러워지지만 열 수만큼 계산이 늘어난다.
먼 눈을 먼저 그리는 이유
01·02 는 눈을 크기 순으로 한 번만 정렬하고 그 순서대로 그린다. 작은 눈이 먼 눈이므로 큰 눈이 위에 놓여 가려지는 모양이 자연스럽고, 프레임마다 정렬하지 않으니 비용도 한 번뿐이다.
눈이 깨지는 순간 — 화면 아래에서 한꺼번에 사라져 뚝 끊기는 눈·쌓인 눈이 솟은 탑처럼 서 버리는 더미·숨은 탭 뒤 한꺼번에 쏟아지는 눈
바닥에서 지우면 눈이 끊긴다
눈이 화면 아래로 나가면 지워 버리는 방식은 눈이 어느 한순간에 한꺼번에 사라진 뒤 위에서 다시 채워지는 틈을 만든다. 03 은 닿은 눈을 높이 지도에 더하고 그 눈을 위에서 다시 내려보내므로 눈 개수가 일정하게 이어진다.
function stepPanel(p, dt) {
const wind = userWind() * 30;
for (const f of p.fl) {
f.y += f.v * dt;
f.x += wind * dt;
const c = clamp(Math.floor(f.x / GRAIN), 0, COLS - 1);
if (f.y >= GROUND_Y - surface(p, c)) {
// 바닥에 닿은 눈은 사라지지 않고 높이 지도에 더해진 뒤 위에서 다시 내려온다
p.h[c] += ADD;
respawn(p, f);
}
}
relax(p);
}
눈더미가 탑처럼 서는 판
03 의 왼쪽 패널은 limit 를 1e9 로 두어 relax 가 바로 돌아오는 실패판이다. 눈이 세 줄기 열에만 쌓이므로 그 열이 이웃과 상관없이 솟아 탑이 된다. 오른쪽 패널은 같은 눈을 REPOSE 1 로 흘려 보내 완만한 언덕이 된다.
숨은 탭 뒤에 한꺼번에 쏟아지는 눈
08 의 왼쪽 패널은 시각표(0.3·1.6·2.9·4.2초)마다 숨은 탭 뒤 돌아온 1.3초를 한 걸음에 쓴다. 눈이 생기는 수가 RATE × dt 에 비례하므로 190 × 1.3 으로 약 247알이 위에서 한꺼번에 쏟아지고, DT_MAX 로 0.05초를 상한으로 자른 오른쪽 패널에는 약 10알만 생긴다.
function update(dt) {
if (hiddenSeen < HIDDEN_AT.length && sceneT >= HIDDEN_AT[hiddenSeen]) {
hiddenSeen += 1;
// 왼쪽은 긴 한 프레임을 그대로 받고, 오른쪽은 DT_MAX 로 자른 만큼만 받는다
stepStorm(storms[0], HIDDEN_S);
stepStorm(storms[1], Math.min(HIDDEN_S, DT_MAX));
return;
}
for (const s of storms) {
stepStorm(s, dt);
}
}
실제 탭이 숨으면 아홉 장면이 쓰는 루프는 멈추고, 돌아오면 lastTs 를 −1 로 돌려 숨어 있던 시간이 첫 프레임에 실리지 않게 한다. 그래도 긴 프레임이 들어올 때를 대비해 DT_MAX 를 한 번 더 건다. 압축 파일은 암호 egxx7s4z 로 열고 03 폴더와 08 폴더의 index.html 을 일시정지 버튼으로 세워 두면 깨진 판과 고친 판이 갈라지는 자리를 눈으로 확인할 수 있다.
돌풍을 세게 하면 눈은 얼마나 휘감길까 — 돌풍 세기·깊이 범위·쌓이는 속도를 바꿔 본 차이
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 돌풍을 세게 | 02 GUST 0.6 → 1.5 |
눈에 더하는 장 속도 계수가 0.6×200=120 에서 1.5×200=300px/초 로 2.5배가 돼 더 큰 원으로 휘감겨 올라가고 한 줄 낙하가 더 빨리 흩어진다 |
| 기울기 한계를 가파르게 | 03 REPOSE 1 → 3 |
이웃 열과 허용하는 높이 차가 3px 에서 9px 로 커져 기울기가 45도에서 약 72도로 가팔라지고 눈더미가 더 뾰족하고 높게 서며 무너짐이 드물어진다 |
| 깊이 범위를 넓게 | 01 Z_SPREAD 0.5 → 1.0 |
속도 배율이 0.6~1.4배에서 0.2~1.8배로 벌어지고 가장 먼 눈의 번짐 반지름이 3.4px 에서 5.1px 로 커져 앞뒤 거리감이 깊어진다 |
값을 바꾸기 전에 볼 것
02 의 GUST 는 속도 계수이지 길이가 아니다. 1.5 로 올리면 장 속도가 300px/초 까지 오르지만, 돌풍 봉우리 몫(0.3~1.0)과 깊이 몫(0.3~1.0)이 그대로 곱해져 실제 휘감김은 그 아래 값이다. REPOSE 를 3 으로 올리면 03 의 오른쪽 패널이 왼쪽 실패판에 가까워지므로 둘을 나란히 두고 비교해야 한다.
방향키로 바람을 바꾸는 눈 장면 — 지금 눈 내리는 상태를 읽어 주는 문장과 움직임 줄이기의 접근성
손 대신 방향키
아홉 장면의 무대 상자는 포커스를 받고, 왼쪽·오른쪽 방향키를 누를 때마다 바람이 WIND_STEP 0.25 씩 −1~1 사이에서 바뀌며 가로 끌기도 같은 바람을 만든다. Enter 와 Space 는 일시정지를 누르고, 사람이 처음 손대면 자동 시연의 바람이 손을 떼고 상태 줄이 aria-live polite 로 바뀐다. 상태 줄은 03 이면 「한계 없음 몇 px · 한계 1 몇 px」 처럼 지금 눈 내리는 상태를 문장으로 읽어 준다.
움직임 줄이기에서는 눈 내린 풍경 한 장
prefers-reduced-motion: reduce 이면 루프를 세우고 REST_STEPS 만큼 계산한 정지 장면 한 장만 그린다. 낙하·돌풍·쌓임은 움직이지 않고 이미 눈이 내려 쌓인 모습만 남으며, 페이지를 연 채 설정을 바꿔도 change 리스너가 다시 읽어 장면을 바꾼다. 눈 내리는 효과 사이트에 붙일 때는 이 설정을 읽는 분기를 지우지 말고 함께 복사하는 편이 좋다. 규격 문서는 MDN 의 prefers-reduced-motion 에 있다.
눈 내리는 효과 FAQ — css 만으로 만들기·휴대폰 부하·배경으로 깔기
눈 내리는 효과 css 만으로도 같은 장면을 만들 수 있나요
01 처럼 크기별 낙하만이라면 css 애니메이션으로 가능하지만, 이 편의 장면은 눈마다 위치를 저장하고 계산해야 한다. 쌓이는 높이 지도, 우산 위 미끄러짐, 가로등 밝기는 프레임마다 값을 읽어 다시 그리는 방식이라 캔버스와 js 가 맞다.
휴대폰에서 눈 개수를 얼마나 둘 수 있나요
장면마다 FLAKES 상수가 눈 개수이고 01 은 240, 06 은 300 이다. 캔버스 내부는 480×300 에 고정하고 devicePixelRatio 도 2 까지만 쓰니 화면이 커져도 계산량은 같다. 그래도 느리면 FLAKES 를 절반으로 줄이는 것부터 시도하면 된다.
페이지 배경으로 깔아도 되나요
01·06·09 처럼 장식인 장면은 히어로 배경에 두기 좋고, 탭이 숨으면 루프가 멈춘다. 글자를 읽어야 하는 구간에는 FLAKES 를 줄이거나 눈 위에 반투명 판을 한 장 두어 글자 대비를 지키는 쪽이 낫다.









