01Cartoon Cloud Outline with Melted-Together Puffs
Distances to several circles are blended with a smooth minimum into one seamless outline, and each puff's radius breathes in and out on a spring so the outline ripples without breaking apart.
Best forWeather illustration on a landing page's first screen
SMOOTH_KPUFF_STIFFNESSPUFF_DAMPING

<main class="stage">
<div class="cl is-demo" id="cl">
<canvas class="cl__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="Two cartoon clouds made of eleven melted-together circles drift side to side; each puff radius swells and settles on a spring while the outline stays unbroken, and dashed circles show where the puffs sat before blending."></canvas>
<p class="cl__status" data-role="status" aria-live="off">Reading the sky</p>
<button class="cl__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cl {
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;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__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 가 눈에도 보이게
.cl__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 하늘(구름 양·해 높이·바람)을 글자로. 시연 중에는 aria-live off, 사람이 처음 버튼을 누르면 polite
.cl__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) {
.cl__pause,
.cl__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cl__pause { transition: none !important; }
.cl__pause:hover,
.cl__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);
}
.cl {
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;
}
.cl__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cl__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);
}
.cl__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cl__pause:hover {
transform: translateY(-2px);
}
.cl__pause:active {
transform: translateY(1px);
}
.cl__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cl__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) {
.cl__pause,
.cl__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl__pause {
transition: none !important;
}
.cl__pause:hover,
.cl__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cl");
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0, edge1, x) { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg) { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (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;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix, iy, seed) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x, y, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x, y, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i, n) { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x, y, period, seed) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x, y, period, octaves, seed) {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
function makeField(gw, gh) { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f, x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) {
return 0;
}
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f, level) {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) {
if (f.v[i] > level) {
n += 1;
}
}
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER = [250, 246, 240];
const INK = [40, 36, 54];
const SKY_HIGH = [124, 160, 222];
const SKY_LOW = [198, 216, 244];
const CLOUD = [253, 251, 255];
const CLOUD_SHADE = [142, 140, 182];
const LAVENDER = [190, 176, 232];
const PEACH = [250, 202, 172];
const ROSE = [236, 168, 182];
const BUTTER = [250, 226, 156];
const MINT = [168, 214, 188];
const GRASS = [160, 204, 146];
const GRASS_SHADE = [76, 118, 98];
const CORAL_DEEP = [176, 66, 54];
const MINT_DEEP = [32, 110, 86];
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) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir) {
if (dir > 0) {
return 'to the right';
}
if (dir < 0) {
return 'to the left';
}
return 'calm';
}
function skyText(cover, sunDeg, windDir) {
return 'Cloud cover {c}% · sun height {s}° · wind {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]');
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) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
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 makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d');
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p, i, c) {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p, dx, dy, dw, dh) {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c) {
ctx.fillStyle = rgba(c, 1);
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, dash) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
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 frameBox(x, y, w, h, r, width, c, a) {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
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, 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, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top, bottom, y0, y1) {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// ── 01 퍼프를 녹여 붙인 만화 구름 윤곽 ───────────────────────
// 퍼프(원) i 까지의 거리 d_i = |p − c_i| − r_i (안쪽이 음수). 이 값들을 부드러운 최솟값으로 차례로 섞어 윤곽 하나로
// smin(a, b) = min(a, b) − h² × SMOOTH_K / 4, h = max(SMOOTH_K − |a − b|, 0) / SMOOTH_K — 두 원이 SMOOTH_K px 안으로 가까워지면 그 사이가 메워진다
const SMOOTH_K = 18;
// 퍼프 반지름 스프링 — 가속 = −PUFF_STIFFNESS × (반지름 − 목표) − PUFF_DAMPING × 속도
const PUFF_STIFFNESS = 60;
const PUFF_DAMPING = 5;
// 1/240초 잔걸음마다 속도를 먼저, 그 속도로 반지름을 고친다(반 걸음 적분)
const PUFF_SUBSTEP = 1 / 240;
// 목표 반지름이 숨 쉬는 폭(원래 반지름의 몫)
const BREATH_AMP = 0.08;
// KICK_EVERY 초마다 퍼프 하나를 정해진 순서로 골라 반지름 속도를 (원래 반지름 × KICK_SHARE) px/초 만큼 밀어 준다 — 스프링이 넘쳤다 돌아오는 모습
const KICK_EVERY = 0.7;
const KICK_SHARE = 2.4;
// 격자 — 가로 240 × 세로 150 칸(칸 하나 2px)에서 거리를 계산해 화면으로 늘린다
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
// 구름 전체가 좌우로 흔들리는 폭(px)과 빠르기(라디안/초)
const SWAY_PX = 26;
const SWAY_RATE = 0.9;
const SUN_DEG_01 = 55;
const LOOP_S = 0;
const START_T = 1.2;
const REST_T = 1.2;
const rand01 = seeded(101);
// 큰 구름 퍼프 7개(가운데 아래쪽이 넓게) + 작은 구름 4개 — 구름 가운데에서 잰 자리(px)
const PUFF_LAYOUT = [
[-96, 22, 38, 0], [-52, -4, 50, 0], [0, -26, 62, 0], [56, -8, 52, 0], [100, 20, 38, 0], [-30, 34, 40, 0], [36, 36, 42, 0],
[-26, 4, 22, 1], [0, -10, 28, 1], [26, 2, 22, 1], [4, 10, 20, 1],
];
const puffs = PUFF_LAYOUT.map(function (p) {
return { ox: p[0], oy: p[1], r0: p[2], r: p[2], v: 0, rate: 1.1 + rand01() * 1.4, phase: rand01() * TAU, cloud: p[3] };
});
const KICK_ORDER = [2, 5, 8, 0, 3, 6, 9, 1, 4, 10, 7];
let lastKick = -1;
const dist01 = makeField(GRID_W, GRID_H);
// 구름 두 개의 가운데 자리 — 큰 것은 좌우로 흔들리고, 작은 것은 to the right 흘러 화면 끝에서 왼쪽 끝으로 돌아온다
function cloudCenter(cloud, t) {
if (cloud === 0) {
return [250 + Math.sin(t * SWAY_RATE) * SWAY_PX, 178];
}
const span = W + 140;
const x = ((t * 22 + 120) % span + span) % span - 70;
return [x, 70];
}
function smin(a, b, k) {
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// 화면 점 (x, y) 에서 구름 윤곽까지의 거리(px, 안쪽 음수) — 퍼프 거리를 차례로 부드럽게 섞는다
function cloudDistance(x, y, k) {
let d = 1e9;
for (let c = 0; c < 2; c += 1) {
const cc = cloudCenter(c, sceneT);
let dc = 1e9;
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
if (p.cloud !== c) {
continue;
}
const di = Math.hypot(x - cc[0] - p.ox, y - cc[1] - p.oy) - p.r;
dc = k > 0 ? smin(dc, di, k) : Math.min(dc, di);
}
d = Math.min(d, dc);
}
return d;
}
function puffTarget(p, t) { return p.r0 * (1 + BREATH_AMP * Math.sin(t * p.rate + p.phase)); }
function springStep(p, h, t) {
const a = -PUFF_STIFFNESS * (p.r - puffTarget(p, t)) - PUFF_DAMPING * p.v;
p.v += a * h;
p.r += p.v * h;
}
function reset() {
for (const p of puffs) {
p.r = p.r0;
p.v = 0;
}
lastKick = -1;
}
function update(dt) {
const k = Math.floor(sceneT / KICK_EVERY);
if (k !== lastKick) {
lastKick = k;
const p = puffs[KICK_ORDER[((k % KICK_ORDER.length) + KICK_ORDER.length) % KICK_ORDER.length]];
p.v += p.r0 * KICK_SHARE;
}
let left = dt;
const t0 = sceneT - dt;
while (left > 1e-9) {
const h = Math.min(PUFF_SUBSTEP, left);
for (const p of puffs) {
springStep(p, h, t0 + (dt - left));
}
left -= h;
}
}
// 격자 칸마다 거리를 채운다 — 화면 쪽이 그리기 직전에 한 번 부른다
function buildField() {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
dist01.v[gy * GRID_W + gx] = cloudDistance((gx + 0.5) * CELL, (gy + 0.5) * CELL, SMOOTH_K);
}
}
}
function insideShare() {
let n = 0;
for (let i = 0; i < dist01.v.length; i += 1) {
if (dist01.v[i] < 0) {
n += 1;
}
}
return n / dist01.v.length;
}
// ── 01 화면 ──────────────────────────────────────────────────
const pix01 = makePix(GRID_W, GRID_H);
// 해 자리(px) — 오른쪽 위
const SUN_X_01 = 404;
const SUN_Y_01 = 58;
const OUTLINE_01 = [104, 96, 158];
// 빛이 오는 쪽 이웃 칸(왼쪽 1칸·위 2칸) — 그 칸이 더 안쪽이면 지금 칸은 빛을 등진 아랫면
const LIGHT_DX = -1;
const LIGHT_DY = -2;
const LIGHT_LEN = Math.hypot(LIGHT_DX, LIGHT_DY) * CELL;
function draw() {
buildField();
const d = pix01.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const sunD = Math.hypot(x - SUN_X_01, y - SUN_Y_01);
let c = mix(sky, BUTTER, clamp(1 - (sunD - 26) / 70, 0, 1) * 0.55);
if (sunD < 26) {
c = BUTTER;
}
const dv = dist01.v[i];
if (dv < 1.5) {
const nx = clamp(gx + LIGHT_DX, 0, GRID_W - 1);
const ny = clamp(gy + LIGHT_DY, 0, GRID_H - 1);
const s = dv - dist01.v[ny * GRID_W + nx];
// 빛 쪽 이웃과의 거리 차로 그늘 — 윤곽 가까운 띠(안쪽 16px)에서만 쓰고, 더 안쪽은 옅은 한 톤(퍼프 사이 골이 줄무늬로 보이지 않게)
const deep = 1 - band(-16, -6, dv);
const shade = band(-LIGHT_LEN * 0.3, LIGHT_LEN, s) * (1 - deep) + 0.12 * deep;
let cl = mix(CLOUD, CLOUD_SHADE, shade * 0.62);
// 윤곽선 — 경계 안쪽 4px 띠를 진한 라벤더로
cl = mix(cl, OUTLINE_01, band(-5, -2.5, dv) * (1 - band(-0.5, 1.2, dv)));
c = mix(c, cl, 1 - band(-0.2, 1.4, dv));
}
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
}
blit(pix01, 0, 0, W, H);
// 퍼프 원 — 섞기 전의 원 자리를 점선으로(녹아 붙은 자리가 원보다 넓게 메워진 것이 보이게)
ctx.setLineDash([3, 4]);
for (const p of puffs) {
const cc = cloudCenter(p.cloud, sceneT);
ring(cc[0] + p.ox, cc[1] + p.oy, p.r, 1, INK, 0.28);
}
ctx.setLineDash([]);
tag('Blend width {k}px'.replace('{k}', String(SMOOTH_K)), 14, H - 20, INK, 11, 'left');
}
function statusText() { return skyText(insideShare(), SUN_DEG_01, 1); }
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');
}
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t) {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) {
advance(h);
}
}
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;
}
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest() {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play() {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { 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();
};
})();// PuffUnionOutline - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PuffUnionOutline.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PuffUnionOutline() {
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; }
// 0~1 사이를 3차로 부드럽게 — 양 끝에서 기울기 0
function smooth01(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// edge0 에서 0, edge1 에서 1 로 부드럽게 넘어가는 문턱
function band(edge0: number, edge1: number, x: number): number { return smooth01((x - edge0) / (edge1 - edge0)); }
function toRad(deg: number): number { return deg * Math.PI / 180; }
// 시드 고정 난수 — 같은 시드면 같은 시연 (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;
};
}
// ── 노이즈 ──────────────────────────────────────────────────
// 격자 꼭짓점마다 정수 해시로 0~1 값 하나 — 같은 (ix, iy, seed) 면 늘 같은 값
function latticeValue(ix: number, iy: number, seed: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263) + Math.imul(seed, 1103515245)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
// 값 노이즈 — 둘레 네 꼭짓점 값을 3차로 부드럽게 섞는다. 결과 0~1
function valueNoise(x: number, y: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = latticeValue(ix, iy, seed);
const b = latticeValue(ix + 1, iy, seed);
const c = latticeValue(ix, iy + 1, seed);
const d = latticeValue(ix + 1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 여러 겹 노이즈 합 — 겹마다 빈도 2배·세기 절반으로 octaves 겹을 더해 전체 세기 합으로 나눈다. 결과 0~1
function layeredNoise(x: number, y: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += valueNoise(x * f, y * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// 정수 나머지를 늘 0 이상으로
function wrapIndex(i: number, n: number): number { const r = i % n; return r < 0 ? r + n : r; }
// 주기 값 노이즈 — 가로 격자 번호를 period 로 나머지 처리해, x 가 period 만큼 가면 처음과 같은 값이 된다
function periodicNoise(x: number, y: number, period: number, seed: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const x0 = wrapIndex(ix, period);
const x1 = wrapIndex(ix + 1, period);
const a = latticeValue(x0, iy, seed);
const b = latticeValue(x1, iy, seed);
const c = latticeValue(x0, iy + 1, seed);
const d = latticeValue(x1, iy + 1, seed);
return lerp(lerp(a, b, ux), lerp(c, d, ux), uy);
}
// 주기 여러 겹 노이즈 합 — 겹마다 빈도와 주기를 함께 2배로 해, 모든 겹이 같은 거리에서 한 바퀴를 돈다
function periodicLayered(x: number, y: number, period: number, octaves: number, seed: number): number {
let sum = 0;
let amp = 1;
let norm = 0;
let f = 1;
for (let k = 0; k < octaves; k += 1) {
sum += periodicNoise(x * f, y * f, period * f, seed + k * 101) * amp;
norm += amp;
amp *= 0.5;
f *= 2;
}
return sum / norm;
}
// ── 짙기 격자 ────────────────────────────────────────────────
// 낮은 해상도 격자 한 장(가로 gw × 세로 gh 칸). 칸 하나 = 화면 W / gw px
type Field = { gw: number; gh: number; v: Float32Array };
function makeField(gw: number, gh: number): Field { return { gw: gw, gh: gh, v: new Float32Array(gw * gh) }; }
// 칸 사이 값을 네 칸 섞어 읽는다 — 격자 밖은 0
function sampleField(f: Field, x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
if (ix < 0 || iy < 0 || ix >= f.gw - 1 || iy >= f.gh - 1) { return 0; }
const fx = x - ix;
const fy = y - iy;
const i = iy * f.gw + ix;
const a = f.v[i];
const b = f.v[i + 1];
const c = f.v[i + f.gw];
const d = f.v[i + f.gw + 1];
return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
}
// 구름 양 — 짙기가 level 을 넘는 칸의 몫(0~1)
function coverOf(f: Field, level: number): number {
let n = 0;
for (let i = 0; i < f.v.length; i += 1) { if (f.v[i] > level) { n += 1; } }
return n / f.v.length;
}
// ── 파스텔 팔레트 ────────────────────────────────────────────
// 하늘·구름·땅은 파스텔, 구름 아랫면·그림자처럼 명도로 읽히는 것은 명도 차를 크게 둔다. 글자는 진한 INK
const PAPER: RGB = [250, 246, 240];
const INK: RGB = [40, 36, 54];
const SKY_HIGH: RGB = [124, 160, 222];
const SKY_LOW: RGB = [198, 216, 244];
const CLOUD: RGB = [253, 251, 255];
const CLOUD_SHADE: RGB = [142, 140, 182];
const LAVENDER: RGB = [190, 176, 232];
const PEACH: RGB = [250, 202, 172];
const ROSE: RGB = [236, 168, 182];
const BUTTER: RGB = [250, 226, 156];
const MINT: RGB = [168, 214, 188];
const GRASS: RGB = [160, 204, 146];
const GRASS_SHADE: RGB = [76, 118, 98];
const CORAL_DEEP: RGB = [176, 66, 54];
const MINT_DEEP: RGB = [32, 110, 86];
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) + ')';
}
// ── 상태 줄 낱말 ─────────────────────────────────────────────
// 지금 하늘 — 구름 양(5% 단위)·해 높이(땅에서 잰 각, 도)·바람 방향. 숫자를 반올림해 글자가 매 프레임 바뀌지 않게
function windName(dir: number): string {
if (dir > 0) { return '오른쪽으로'; }
if (dir < 0) { return '왼쪽으로'; }
return '잔잔함';
}
function skyText(cover: number, sunDeg: number, windDir: number): string {
return '구름 양 {c}% · 해 높이 {s}° · 바람 {w}'
.replace('{c}', String(Math.round(clamp(cover, 0, 1) * 20) * 5))
.replace('{s}', String(Math.round(sunDeg)))
.replace('{w}', windName(windDir));
}
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | 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) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
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);
}
// 낮은 해상도 화소 판 — 화면 밖 캔버스 하나와 ImageData 하나를 만들어 두고 매 프레임 다시 쓴다
type Pix = { w: number; h: number; img: ImageData; c: HTMLCanvasElement; x: CanvasRenderingContext2D };
function makePix(w: number, h: number): Pix {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const x = c.getContext('2d') as CanvasRenderingContext2D;
return { w: w, h: h, img: x.createImageData(w, h), c: c, x: x };
}
// 화소 하나 쓰기(불투명)
function putPx(p: Pix, i: number, c: RGB): void {
const d = p.img.data;
const k = i * 4;
d[k] = c[0];
d[k + 1] = c[1];
d[k + 2] = c[2];
d[k + 3] = 255;
}
// 판을 화면 자리 (dx, dy, dw, dh) 로 늘려 그린다 — 부드럽게 늘려 칸 경계가 보이지 않게
function blit(p: Pix, dx: number, dy: number, dw: number, dh: number): void {
p.x.putImageData(p.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(p.c, dx, dy, dw, dh);
}
function fillAll(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
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, dash: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.setLineDash(dash > 0 ? [dash, dash] : []);
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
ctx.setLineDash([]);
}
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 frameBox(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
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, 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, PAPER, 0.92);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 위아래 두 색 하늘 — 화면 쪽 꾸밈 한 겹
function skyGradient(top: RGB, bottom: RGB, y0: number, y1: number): void {
const g = ctx.createLinearGradient(0, y0, 0, y1);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y0, W, y1 - y0);
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// ── 01 퍼프를 녹여 붙인 만화 구름 윤곽 ───────────────────────
// 퍼프(원) i 까지의 거리 d_i = |p − c_i| − r_i (안쪽이 음수). 이 값들을 부드러운 최솟값으로 차례로 섞어 윤곽 하나로
// smin(a, b) = min(a, b) − h² × SMOOTH_K / 4, h = max(SMOOTH_K − |a − b|, 0) / SMOOTH_K — 두 원이 SMOOTH_K px 안으로 가까워지면 그 사이가 메워진다
const SMOOTH_K = 18;
// 퍼프 반지름 스프링 — 가속 = −PUFF_STIFFNESS × (반지름 − 목표) − PUFF_DAMPING × 속도
const PUFF_STIFFNESS = 60;
const PUFF_DAMPING = 5;
// 1/240초 잔걸음마다 속도를 먼저, 그 속도로 반지름을 고친다(반 걸음 적분)
const PUFF_SUBSTEP = 1 / 240;
// 목표 반지름이 숨 쉬는 폭(원래 반지름의 몫)
const BREATH_AMP = 0.08;
// KICK_EVERY 초마다 퍼프 하나를 정해진 순서로 골라 반지름 속도를 (원래 반지름 × KICK_SHARE) px/초 만큼 밀어 준다 — 스프링이 넘쳤다 돌아오는 모습
const KICK_EVERY = 0.7;
const KICK_SHARE = 2.4;
// 격자 — 가로 240 × 세로 150 칸(칸 하나 2px)에서 거리를 계산해 화면으로 늘린다
const GRID_W = 240;
const GRID_H = 150;
const CELL = W / GRID_W;
// 구름 전체가 좌우로 흔들리는 폭(px)과 빠르기(라디안/초)
const SWAY_PX = 26;
const SWAY_RATE = 0.9;
const SUN_DEG_01 = 55;
const LOOP_S = 0;
const START_T = 1.2;
const REST_T = 1.2;
type Puff = { ox: number; oy: number; r0: number; r: number; v: number; rate: number; phase: number; cloud: number };
const rand01 = seeded(101);
// 큰 구름 퍼프 7개(가운데 아래쪽이 넓게) + 작은 구름 4개 — 구름 가운데에서 잰 자리(px)
const PUFF_LAYOUT: number[][] = [
[-96, 22, 38, 0], [-52, -4, 50, 0], [0, -26, 62, 0], [56, -8, 52, 0], [100, 20, 38, 0], [-30, 34, 40, 0], [36, 36, 42, 0],
[-26, 4, 22, 1], [0, -10, 28, 1], [26, 2, 22, 1], [4, 10, 20, 1],
];
const puffs: Puff[] = PUFF_LAYOUT.map(function (p: number[]): Puff {
return { ox: p[0], oy: p[1], r0: p[2], r: p[2], v: 0, rate: 1.1 + rand01() * 1.4, phase: rand01() * TAU, cloud: p[3] };
});
const KICK_ORDER: number[] = [2, 5, 8, 0, 3, 6, 9, 1, 4, 10, 7];
let lastKick = -1;
const dist01 = makeField(GRID_W, GRID_H);
// 구름 두 개의 가운데 자리 — 큰 것은 좌우로 흔들리고, 작은 것은 오른쪽으로 흘러 화면 끝에서 왼쪽 끝으로 돌아온다
function cloudCenter(cloud: number, t: number): [number, number] {
if (cloud === 0) { return [250 + Math.sin(t * SWAY_RATE) * SWAY_PX, 178]; }
const span = W + 140;
const x = ((t * 22 + 120) % span + span) % span - 70;
return [x, 70];
}
function smin(a: number, b: number, k: number): number {
const h = Math.max(k - Math.abs(a - b), 0) / k;
return Math.min(a, b) - h * h * k / 4;
}
// 화면 점 (x, y) 에서 구름 윤곽까지의 거리(px, 안쪽 음수) — 퍼프 거리를 차례로 부드럽게 섞는다
function cloudDistance(x: number, y: number, k: number): number {
let d = 1e9;
for (let c = 0; c < 2; c += 1) {
const cc = cloudCenter(c, sceneT);
let dc = 1e9;
for (let i = 0; i < puffs.length; i += 1) {
const p = puffs[i];
if (p.cloud !== c) { continue; }
const di = Math.hypot(x - cc[0] - p.ox, y - cc[1] - p.oy) - p.r;
dc = k > 0 ? smin(dc, di, k) : Math.min(dc, di);
}
d = Math.min(d, dc);
}
return d;
}
function puffTarget(p: Puff, t: number): number { return p.r0 * (1 + BREATH_AMP * Math.sin(t * p.rate + p.phase)); }
function springStep(p: Puff, h: number, t: number): void {
const a = -PUFF_STIFFNESS * (p.r - puffTarget(p, t)) - PUFF_DAMPING * p.v;
p.v += a * h;
p.r += p.v * h;
}
function reset(): void {
for (const p of puffs) { p.r = p.r0; p.v = 0; }
lastKick = -1;
}
function update(dt: number): void {
const k = Math.floor(sceneT / KICK_EVERY);
if (k !== lastKick) {
lastKick = k;
const p = puffs[KICK_ORDER[((k % KICK_ORDER.length) + KICK_ORDER.length) % KICK_ORDER.length]];
p.v += p.r0 * KICK_SHARE;
}
let left = dt;
const t0 = sceneT - dt;
while (left > 1e-9) {
const h = Math.min(PUFF_SUBSTEP, left);
for (const p of puffs) { springStep(p, h, t0 + (dt - left)); }
left -= h;
}
}
// 격자 칸마다 거리를 채운다 — 화면 쪽이 그리기 직전에 한 번 부른다
function buildField(): void {
for (let gy = 0; gy < GRID_H; gy += 1) {
for (let gx = 0; gx < GRID_W; gx += 1) {
dist01.v[gy * GRID_W + gx] = cloudDistance((gx + 0.5) * CELL, (gy + 0.5) * CELL, SMOOTH_K);
}
}
}
function insideShare(): number {
let n = 0;
for (let i = 0; i < dist01.v.length; i += 1) { if (dist01.v[i] < 0) { n += 1; } }
return n / dist01.v.length;
}
// ── 01 화면 ──────────────────────────────────────────────────
const pix01 = makePix(GRID_W, GRID_H);
// 해 자리(px) — 오른쪽 위
const SUN_X_01 = 404;
const SUN_Y_01 = 58;
const OUTLINE_01: RGB = [104, 96, 158];
// 빛이 오는 쪽 이웃 칸(왼쪽 1칸·위 2칸) — 그 칸이 더 안쪽이면 지금 칸은 빛을 등진 아랫면
const LIGHT_DX = -1;
const LIGHT_DY = -2;
const LIGHT_LEN = Math.hypot(LIGHT_DX, LIGHT_DY) * CELL;
function draw(): void {
buildField();
const d = pix01.img.data;
for (let gy = 0; gy < GRID_H; gy += 1) {
const sky = mix(SKY_HIGH, SKY_LOW, gy / GRID_H);
for (let gx = 0; gx < GRID_W; gx += 1) {
const i = gy * GRID_W + gx;
const x = (gx + 0.5) * CELL;
const y = (gy + 0.5) * CELL;
const sunD = Math.hypot(x - SUN_X_01, y - SUN_Y_01);
let c = mix(sky, BUTTER, clamp(1 - (sunD - 26) / 70, 0, 1) * 0.55);
if (sunD < 26) { c = BUTTER; }
const dv = dist01.v[i];
if (dv < 1.5) {
const nx = clamp(gx + LIGHT_DX, 0, GRID_W - 1);
const ny = clamp(gy + LIGHT_DY, 0, GRID_H - 1);
const s = dv - dist01.v[ny * GRID_W + nx];
// 빛 쪽 이웃과의 거리 차로 그늘 — 윤곽 가까운 띠(안쪽 16px)에서만 쓰고, 더 안쪽은 옅은 한 톤(퍼프 사이 골이 줄무늬로 보이지 않게)
const deep = 1 - band(-16, -6, dv);
const shade = band(-LIGHT_LEN * 0.3, LIGHT_LEN, s) * (1 - deep) + 0.12 * deep;
let cl = mix(CLOUD, CLOUD_SHADE, shade * 0.62);
// 윤곽선 — 경계 안쪽 4px 띠를 진한 라벤더로
cl = mix(cl, OUTLINE_01, band(-5, -2.5, dv) * (1 - band(-0.5, 1.2, dv)));
c = mix(c, cl, 1 - band(-0.2, 1.4, dv));
}
const k = i * 4;
d[k] = c[0]; d[k + 1] = c[1]; d[k + 2] = c[2]; d[k + 3] = 255;
}
}
blit(pix01, 0, 0, W, H);
// 퍼프 원 — 섞기 전의 원 자리를 점선으로(녹아 붙은 자리가 원보다 넓게 메워진 것이 보이게)
ctx.setLineDash([3, 4]);
for (const p of puffs) {
const cc = cloudCenter(p.cloud, sceneT);
ring(cc[0] + p.ox, cc[1] + p.oy, p.r, 1, INK, 0.28);
}
ctx.setLineDash([]);
tag('녹임 폭 {k}px'.replace('{k}', String(SMOOTH_K)), 14, H - 20, INK, 11, 'left');
}
function statusText(): string { return skyText(insideShare(), SUN_DEG_01, 1); }
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'); }
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (LOOP_S > 0 && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
// 처음부터 t 초까지 1/60초 고정 걸음으로 감아 둔다 — 첫 화면·정지 장면이 빈 하늘로 시작하지 않게
function runTo(t: number): void {
sceneT = 0;
reset();
const h = 1 / 60;
while (sceneT + h <= t) { advance(h); }
}
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; }
}
// 움직임 줄이기 — 루프 없이 대표 순간(REST_T 초)의 하늘 한 장
function showRest(): void {
stopLoop();
runTo(REST_T);
draw();
pushStatus(statusText());
}
// 자동 시연은 START_T 초 자리에서 시작한다(첫 화면에 장면 핵심이 보이게). LOOP_S 가 있는 장면은 한 바퀴를 돌면 0초부터
function play(): void {
runTo(START_T);
draw();
pushStatus(statusText());
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { 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("cl") + " is-demo"} ref={rootRef}>
<canvas className={cx("cl__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'원 열한 개가 녹아 붙은 만화 구름 두 덩이가 좌우로 흐르고, 퍼프마다 반지름이 스프링으로 부풀었다 가라앉아도 윤곽이 끊기지 않으며, 점선 원이 섞기 전 퍼프 자리를 보여 준다'}></canvas>
<p className={cx("cl__status")} data-role="status" aria-live="off">{'하늘을 읽는 중'}</p>
<button className={cx("cl__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 Cartoon Cloud Outline with Melted-Together Puffs — Measured area 23.124% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Cloud Animation: 9 Sky Scenes in Canvas
The post this demo lives in
CSS Cloud Animation: 9 Sky Scenes in Canvascss cloud animation: Why is a puffy cloud's base so flat? 03 adds puffs only above the height where rising air condenses See all demos in this post


