05A Puddle Dragged by the Magnet While Tension Keeps It Round
The rim of the puddle is a ring of points, and each frame adds a pull toward the magnet sized by the slope of the field strength, a surface tension that draws each point back between its two neighbors, a pressure that keeps the starting area and a viscous damping, so when the magnet runs ahead the puddle stretches toward it into a teardrop and follows.
Best forPull effect on an interactive exhibition page
SURFACE_TENSIONAREA_PRESSUREPUDDLE_DT_MAX

<main class="stage">
<div class="ff is-demo" id="ff">
<canvas class="ff__canvas" data-role="canvas" width="480" height="300" role="img" tabindex="0" aria-label="Two black puddles side by side dragged after a fast moving magnet; when one long frame arrives, only the left one, whose time step is not capped, blows apart. Arrow keys move the magnet"></canvas>
<p class="ff__status" data-role="status" aria-live="off">Puddle being dragged</p>
<button class="ff__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.ff {
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;
}
// 캔버스 — 누른 채 끌어 자석을 옮기므로, 이 위에서만 브라우저 기본 끌기(스크롤·확대)를 끈다
.ff__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
border-radius: $r-card;
}
// 초점 — 키보드로 들어오면 캔버스 안쪽에 진한 테두리
.ff__canvas:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.ff__pause {
position: absolute;
right: $sp-3;
bottom: $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 가 눈에도 보이게
.ff__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.ff__pause:hover {
transform: translateY(-2px);
}
.ff__pause:active {
transform: translateY(1px);
}
.ff__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 세기와 가시가 선 범위를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.ff__status {
position: absolute;
left: $sp-3;
bottom: $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) {
.ff__pause,
.ff__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 움직임 줄이기 — 판은 스크립트가 멈춘 한 장으로 그린다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.ff__pause { transition: none !important; }
.ff__pause:hover,
.ff__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);
}
.ff {
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;
}
.ff__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: crosshair;
border-radius: 16px;
}
.ff__canvas:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.ff__pause {
position: absolute;
right: 12px;
bottom: 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);
}
.ff__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.ff__pause:hover {
transform: translateY(-2px);
}
.ff__pause:active {
transform: translateY(1px);
}
.ff__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.ff__status {
position: absolute;
left: 12px;
bottom: 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) {
.ff__pause,
.ff__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ff__pause {
transition: none !important;
}
.ff__pause:hover,
.ff__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("ff");
if (!root) return;
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 계산 좌표 — 캔버스 안은 늘 480×300. 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
// 사람이 손댔는가 — 손대면 자석(또는 장면의 조절점)이 정해 둔 길 대신 userX·userY 를 따른다
let handsOn = false;
let userX = W / 2;
let userY = H / 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 을 부드러운 계단(3x² − 2x³)으로
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 1 − e^(−rate·dt) — 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 각을 −π ~ π 로 감는다 — 두 각의 차가 한 바퀴를 넘어 돌아가는 길을 고르지 않게
function wrapAngle(a) {
let x = a % TAU;
if (x > Math.PI) {
x -= TAU;
}
if (x < -Math.PI) {
x += TAU;
}
return x;
}
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (32비트 자리 밀기 섞기 세 번)
function makeRng(seed) {
let s = (seed >>> 0) || 1;
return function () {
s ^= s << 13;
s >>>= 0;
s ^= s >>> 17;
s ^= s << 5;
s >>>= 0;
return s / 4294967296;
};
}
// 상태 줄 틀 채우기 — {이름} 자리에 값
function fill(t, v) {
return t.replace(/\{(\w+)\}/g, function (m, k) { return k in v ? String(v[k]) : m; });
}
const SPRING_SUBSTEP = 1 / 240;
function makeSpring(x) { return { x: x, v: 0, to: x }; }
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
// 스프링을 그 값에 바로 앉힌다(속도 0) — 움직임 줄이기·첫 장면
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
// 각도 스프링 — 목표와의 차를 −π ~ π 로 감은 뒤 같은 식으로
function stepAngleSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * wrapAngle(s.to - s.x) - damping * s.v) * h;
s.x = wrapAngle(s.x + s.v * h);
left -= h;
}
}
// ── 팔레트 (RGB) — 액체는 거의 검정(기능색), 조명·바탕·자기력선은 파스텔, 글자는 진한 색 ──
const PAPER = [250, 247, 242];
const DISH = [234, 238, 247];
const DISH_DEEP = [214, 220, 236];
const FLUID = [13, 12, 17];
const FLUID_EDGE = [86, 80, 112];
const LIGHT_PINK = [255, 190, 210];
const LIGHT_SKY = [172, 212, 255];
const LAVENDER = [198, 186, 238];
const MINT = [168, 222, 200];
const BUTTER = [248, 228, 162];
const POLE_N = [244, 180, 198];
const POLE_S = [170, 204, 240];
const INK = [38, 34, 50];
const INK_SOFT = [104, 98, 122];
const VIOLET_DEEP = [82, 58, 158];
const CORAL_DEEP = [174, 62, 52];
const CREAM = [255, 251, 244];
const FIELD_REF_PX = 60;
const field = [0, 0, 0];
function fieldAt(poles, x, y, soften) {
let bx = 0;
let by = 0;
let bz = 0;
const ref2 = FIELD_REF_PX * FIELD_REF_PX;
for (const p of poles) {
const dx = x - p.x;
const dy = y - p.y;
const r2 = dx * dx + dy * dy + soften * soften;
const k = p.q * ref2 / (r2 * Math.sqrt(r2));
bx += dx * k;
by += dy * k;
bz += soften * k;
}
field[0] = bx;
field[1] = by;
field[2] = bz;
return Math.sqrt(bx * bx + by * by + bz * bz);
}
// ── 엇갈린 세 물결 ──────────────────────────────────────────
// 방향이 0°·120°·240° 인 cos 물결 셋을 더하면(−1.5 ~ 3) 세 마루가 겹치는 점이 육각 배열로 놓인다.
// 마루 사이 거리 = spacing 이 되도록 물결수 k = 4π / (√3 · spacing). 합을 3 으로 나눈 값에서 HEX_FLOOR 아래는 잘라
// 가시 사이를 평평하게 두고, 남은 윗부분을 0~1 로 늘린다(꼭대기 쪽이 곧게 좁아지는 원뿔 모양)
const HEX_C = 0.5;
const HEX_S = Math.sqrt(3) / 2;
const HEX_FLOOR = 0.3;
function hexWave(x, y, spacing) {
const k = 4 * Math.PI / (Math.sqrt(3) * spacing);
const s = Math.cos(k * x) + Math.cos(k * (-HEX_C * x + HEX_S * y)) + Math.cos(k * (-HEX_C * x - HEX_S * y));
return Math.max(0, (s / 3 - HEX_FLOOR) / (1 - HEX_FLOOR));
}
function makeGrid(ox, oy, w, h, cell) {
const gw = Math.ceil(w / cell) + 1;
const gh = Math.ceil(h / cell) + 1;
const n = gw * gh;
return { gw: gw, gh: gh, cell: cell, ox: ox, oy: oy, z: new Float32Array(n), wet: new Float32Array(n), amp: new Float32Array(n), vel: new Float32Array(n), target: new Float32Array(n), rgba: new Uint8ClampedArray(n * 4) };
}
// 칸마다 가시 높이 스프링을 목표(target)로 — 같은 반암시 적분, 1/240초 잔걸음
function stepGridSprings(g, stiffness, damping, dt) {
let left = dt;
const n = g.gw * g.gh;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
for (let i = 0; i < n; i += 1) {
g.vel[i] += (stiffness * (g.target[i] - g.amp[i]) - damping * g.vel[i]) * h;
g.amp[i] += g.vel[i] * h;
}
left -= h;
}
}
function settleGrid(g) {
g.amp.set(g.target);
g.vel.fill(0);
}
// 기울기 색 섞기 — 면이 기운 만큼(1 − n_z) × TILT_MIX 로 가장자리 색을 섞어 가시 옆면이 바탕 검정과 갈리게
const TILT_MIX = 0.5;
function halfVectors(lights) {
return lights.map(function (l) {
const ll = Math.hypot(l.x, l.y, l.z) || 1;
const hx = l.x / ll;
const hy = l.y / ll;
const hz = l.z / ll + 1;
const hl = Math.hypot(hx, hy, hz) || 1;
return { x: hx / hl, y: hy / hl, z: hz / hl, c: l.c, k: l.k };
});
}
// 한 점 칠하기 — 기울기(gx, gy)·덮인 정도 wet·바탕색 bg 로 out[o..o+3] 에 RGBA.
// bg 가 null 이면 바탕과 섞지 않고 액체 색에 투명도 wet 을 준다(아래 그림 위에 겹쳐 붙일 때)
function shadeOne(gx, gy, wet, bg, hv, shine, out, o) {
const inv = 1 / Math.sqrt(gx * gx + gy * gy + 1);
const nx = -gx * inv;
const ny = -gy * inv;
const nz = inv;
const tilt = (1 - nz) * TILT_MIX;
let r = lerp(FLUID[0], FLUID_EDGE[0], tilt);
let g = lerp(FLUID[1], FLUID_EDGE[1], tilt);
let b = lerp(FLUID[2], FLUID_EDGE[2], tilt);
for (const h of hv) {
const d = nx * h.x + ny * h.y + nz * h.z;
if (d <= 0) {
continue;
}
const s = Math.pow(d, shine) * h.k;
r += h.c[0] * s;
g += h.c[1] * s;
b += h.c[2] * s;
}
const a = clamp(wet, 0, 1);
if (!bg) {
out[o] = Math.min(255, r);
out[o + 1] = Math.min(255, g);
out[o + 2] = Math.min(255, b);
out[o + 3] = a * 255;
return;
}
out[o] = lerp(bg[0], Math.min(255, r), a);
out[o + 1] = lerp(bg[1], Math.min(255, g), a);
out[o + 2] = lerp(bg[2], Math.min(255, b), a);
out[o + 3] = 255;
}
// 격자 전체 칠하기 — 칸 하나에 한 번. 끝 칸은 안쪽 이웃으로 한쪽 차
function shadeGrid(g, bg, lights, shine) {
const hv = halfVectors(lights);
const gw = g.gw;
const gh = g.gh;
const z = g.z;
let count = 0;
for (let j = 0; j < gh; j += 1) {
const ju = j > 0 ? j - 1 : j;
const jd = j < gh - 1 ? j + 1 : j;
for (let i = 0; i < gw; i += 1) {
const il = i > 0 ? i - 1 : i;
const ir = i < gw - 1 ? i + 1 : i;
const k = j * gw + i;
const gx = (z[j * gw + ir] - z[j * gw + il]) / ((ir - il) * g.cell);
const gy = (z[jd * gw + i] - z[ju * gw + i]) / ((jd - ju) * g.cell);
shadeOne(gx, gy, g.wet[k], bg, hv, shine, g.rgba, k * 4);
count += 1;
}
}
return count;
}
const TENSION_RATE = 900;
function makeRing(cx, cy, r, n) {
const ring = { n: n, x: [], y: [], vx: [], vy: [], area0: 0 };
for (let i = 0; i < n; i += 1) {
const a = i / n * TAU;
ring.x.push(cx + Math.cos(a) * r);
ring.y.push(cy + Math.sin(a) * r);
ring.vx.push(0);
ring.vy.push(0);
}
ring.area0 = ringArea(ring);
return ring;
}
// 넓이 — 꼭짓점을 차례로 잇는 다각형의 넓이(두 점의 외적을 반씩 더함). makeRing 처럼 각도가 커지는 차례로 놓으면 양수
function ringArea(r) {
let a = 0;
for (let i = 0; i < r.n; i += 1) {
const j = (i + 1) % r.n;
a += r.x[i] * r.y[j] - r.x[j] * r.y[i];
}
return a / 2;
}
function ringCenter(r) {
let x = 0;
let y = 0;
for (let i = 0; i < r.n; i += 1) {
x += r.x[i];
y += r.y[i];
}
return [x / r.n, y / r.n];
}
// Field² 의 기울기 — 1px 떨어진 네 점의 차로
function strength2Grad(poles, x, y, soften) {
const l = fieldAt(poles, x - 1, y, soften);
const r = fieldAt(poles, x + 1, y, soften);
const u = fieldAt(poles, x, y - 1, soften);
const d = fieldAt(poles, x, y + 1, soften);
return [(r * r - l * l) / 2, (d * d - u * u) / 2];
}
const ringAx = [];
const ringAy = [];
function ringAccel(r, poles, p) {
const n = r.n;
const area = ringArea(r);
const squeeze = p.pressure * (r.area0 - area) / Math.max(1, Math.abs(r.area0));
for (let i = 0; i < n; i += 1) {
const a = (i + n - 1) % n;
const b = (i + 1) % n;
const mx = (r.x[a] + r.x[b]) / 2;
const my = (r.y[a] + r.y[b]) / 2;
// 바깥 방향 — 앞뒤 점을 잇는 방향 (tx, ty) 를 (ty, −tx) 로 돌린 쪽. 넓이가 양수가 되는 차례로 놓인 고리에서는 이쪽이 바깥
const tx = r.x[b] - r.x[a];
const ty = r.y[b] - r.y[a];
const tl = Math.hypot(tx, ty) || 1;
const g = strength2Grad(poles, r.x[i], r.y[i], p.soften);
ringAx[i] = p.drag * g[0] + p.tension * TENSION_RATE * (mx - r.x[i]) + squeeze * (ty / tl) - p.viscosity * r.vx[i];
ringAy[i] = p.drag * g[1] + p.tension * TENSION_RATE * (my - r.y[i]) + squeeze * (-tx / tl) - p.viscosity * r.vy[i];
}
}
// 한 걸음 — 넘겨받은 dt 를 그대로 한 번에(속도 먼저, 위치 다음). 자르지도 나누지도 않는다
function stepRingRaw(r, poles, p, dt) {
ringAccel(r, poles, p);
for (let i = 0; i < r.n; i += 1) {
r.vx[i] += ringAx[i] * dt;
r.vy[i] += ringAy[i] * dt;
r.x[i] += r.vx[i] * dt;
r.y[i] += r.vy[i] * dt;
}
}
// 나눠 걷기 — dt 를 PUDDLE_DT_MAX 에서 자르고, 그것을 PUDDLE_SUBSTEP 이하 잔걸음으로 쪼개 위 걸음을 되풀이
const PUDDLE_DT_MAX = 0.05;
const PUDDLE_SUBSTEP = 1 / 240;
function stepRingSafe(r, poles, p, dt) {
let left = Math.min(dt, PUDDLE_DT_MAX);
while (left > 1e-9) {
const h = Math.min(PUDDLE_SUBSTEP, left);
stepRingRaw(r, poles, p, h);
left -= h;
}
}
// 고리 안을 높이 격자의 덮인 정도로 — 줄마다 다각형 변과 만나는 x 를 모아 짝수 번째와 홀수 번째 사이를 채운다
function ringToWet(r, g, out) {
out.fill(0);
const xs = [];
for (let j = 0; j < g.gh; j += 1) {
const y = g.oy + j * g.cell;
xs.length = 0;
for (let i = 0; i < r.n; i += 1) {
const k = (i + 1) % r.n;
const y0 = r.y[i];
const y1 = r.y[k];
if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) {
xs.push(r.x[i] + (y - y0) / (y1 - y0) * (r.x[k] - r.x[i]));
}
}
xs.sort(function (a, b) { return a - b; });
for (let m = 0; m + 1 < xs.length; m += 2) {
const i0 = Math.max(0, Math.ceil((xs[m] - g.ox) / g.cell));
const i1 = Math.min(g.gw - 1, Math.floor((xs[m + 1] - g.ox) / g.cell));
for (let i = i0; i <= i1; i += 1) {
out[j * g.gw + i] = 1;
}
}
}
}
// 덮인 정도를 이웃 평균으로 times 번 — 가장자리를 둥글게(돔 높이·부드러운 테두리)
function blurWet(g, src, times, tmp) {
for (let t = 0; t < times; t += 1) {
for (let j = 0; j < g.gh; j += 1) {
for (let i = 0; i < g.gw; i += 1) {
const k = j * g.gw + i;
const l = i > 0 ? src[k - 1] : 0;
const r = i < g.gw - 1 ? src[k + 1] : 0;
const u = j > 0 ? src[k - g.gw] : 0;
const d = j < g.gh - 1 ? src[k + g.gw] : 0;
tmp[k] = (src[k] * 2 + l + r + u + d) / 6;
}
}
src.set(tmp);
}
}
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';
// 화소 배율 — 캔버스 내부 크기에 곱한다. 2 에서 자른다(배율 3 기기에서 화소 수가 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas() {
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 rgba(c, a) {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function makeBlitter() {
const off = document.createElement('canvas');
const offCtx = off.getContext('2d');
let img = null;
return function (data, gw, gh, x, y, w, h) {
if (!offCtx) {
return;
}
if (!img || img.width !== gw || img.height !== gh) {
off.width = gw;
off.height = gh;
img = offCtx.createImageData(gw, gh);
}
img.data.set(data);
offCtx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, 0, gw, gh, x, y, w, h);
};
}
// 높이 격자 한 장 — 칸 가운데가 표본 점이라 반 칸 바깥에서부터 늘린다
const blitMain = makeBlitter();
function drawGrid(g) {
blitMain(g.rgba, g.gw, g.gh, g.ox - g.cell / 2, g.oy - g.cell / 2, g.gw * g.cell, g.gh * g.cell);
}
// ── 그리기 도구 ─────────────────────────────────────────────
function clearStage(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px, weight) { ctx.font = weight + ' ' + px.toFixed(1) + 'px ' + FONT_STACK; }
function label(s, x, y, c, a, px, weight, align) {
setFont(px, weight);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function fillRound(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
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 line(x0, y0, x1, y1, width, c, a) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 이름표 — 둥근 바탕 위 글자
function tag(s, x, y, bg, fg, a, px, align) {
setFont(px, 700);
const w = ctx.measureText(s).width + px * 1.2;
const x0 = align === 'left' ? x : (align === 'right' ? x - w : x - w / 2);
fillRound(x0, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x0 + w / 2, y + 0.5, fg, a, px, 700, 'center');
}
// 자석 막대 — 가운데 (x, y), 방향 각 a, 반 길이 half. 앞쪽(N) 분홍·뒤쪽(S) 하늘색 두 칸
function drawBarMagnet(x, y, a, half, thick, alpha) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.globalAlpha = alpha;
fillRound(-half, -thick / 2, half, thick, thick * 0.3, POLE_S, 1);
fillRound(0, -thick / 2, half, thick, thick * 0.3, POLE_N, 1);
roundRectPath(-half, -thick / 2, half * 2, thick, thick * 0.3);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(INK, 0.75);
ctx.stroke();
label('N', half * 0.5, 0.5, INK, 0.9, thick * 0.62, 800, 'center');
label('S', -half * 0.5, 0.5, INK, 0.9, thick * 0.62, 800, 'center');
ctx.restore();
}
// ── 사람 손 — 포인터 끌기와 키 ──────────────────────────────
// 캔버스를 누른 채 움직이면 그 자리가 조절점(userX, userY)이 된다. 키보드는 캔버스에 초점이 있을 때만:
// 화살표 8px(Shift 24px) 옮기기 · Enter·Space 가운데로 되돌리기. 항목이 onNudge·onPoint 를 바꿔 끼울 수 있다
let pressing = false;
function eventPoint(e) {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / Math.max(1, r.width), (e.clientY - r.top) * H / Math.max(1, r.height)];
}
let onPoint = function (x, y) {
userX = clamp(x, 0, W);
userY = clamp(y, 0, H);
};
let onNudge = function (dx, dy, center) {
if (center) {
userX = W / 2;
userY = H / 2;
return;
}
userX = clamp(userX + dx, 0, W);
userY = clamp(userY + dy, 0, H);
};
function bindInput(signal) {
cv.addEventListener('pointerdown', function (e) {
takeOver();
pressing = true;
cv.setPointerCapture(e.pointerId);
const p = eventPoint(e);
onPoint(p[0], p[1]);
afterInput();
}, { signal: signal });
cv.addEventListener('pointermove', function (e) {
if (!pressing) {
return;
}
const p = eventPoint(e);
onPoint(p[0], p[1]);
if (still || paused) {
afterInput();
}
}, { signal: signal });
const end = function () { pressing = false; };
cv.addEventListener('pointerup', end, { signal: signal });
cv.addEventListener('pointercancel', end, { signal: signal });
cv.addEventListener('keydown', function (e) {
const step = e.shiftKey ? 24 : 8;
let dx = 0;
let dy = 0;
let center = false;
if (e.key === 'ArrowLeft') {
dx = -step;
}
else if (e.key === 'ArrowRight') {
dx = step;
}
else if (e.key === 'ArrowUp') {
dy = -step;
}
else if (e.key === 'ArrowDown') {
dy = step;
}
else if (e.key === 'Enter' || e.key === ' ') {
center = true;
}
else {
return;
}
e.preventDefault();
takeOver();
onNudge(dx, dy, center);
afterInput();
}, { signal: signal });
}
// ── 05 자석을 따라 끌려가며 둥글게 버티는 웅덩이 ─────────────
// 웅덩이 둘 — 왼쪽 반과 오른쪽 반에 같은 웅덩이·같은 자석 길. 다른 것은 긴 한 프레임을 받는 방법 하나
// 왼쪽: 넘겨받은 dt 를 그대로 한 걸음(stepRingRaw) · 오른쪽: PUDDLE_DT_MAX 에서 자르고 잔걸음으로 나눔(stepRingSafe)
const SURFACE_TENSION = 0.22;
const AREA_PRESSURE = 20000;
const DRAG_GAIN = 24000;
const VISCOSITY = 4;
const MAG_DEPTH_PX = 40;
const RING_POINTS = 40;
const PUDDLE_R = 46;
// 자석 길 — 반 가운데 둘레를 가로 ± PATH_X, 세로 ± PATH_Y 로 PATH_S 초에 한 바퀴(옆으로 누운 8자)
const PATH_X = 50;
const PATH_Y = 30;
const PATH_S = 3;
// 숨은 탭 흉내 — HIDDEN_AT_S 초에 한 번, HIDDEN_GAP_S 초가 한 프레임에 몰려 들어온다(자석은 그만큼 앞서 간 자리로)
const HIDDEN_AT_S = 1.15;
const HIDDEN_GAP_S = 0.8;
const LOOP_S = 3.4;
const STATUS_SAFE = 'area after the long frame: raw ×{l} · capped ×{r} · tail {t}px';
const STATUS_RUN = 'before the long frame: tail {t}px · area ×{r}';
const PARAMS = { tension: SURFACE_TENSION, pressure: AREA_PRESSURE, drag: DRAG_GAIN, viscosity: VISCOSITY, soften: MAG_DEPTH_PX };
const CX = [W / 4, W * 3 / 4];
const CY = H / 2 - 6;
let rings = [];
let magnets = [];
let burst = false;
let gapDone = false;
let leftRatio = 1;
// 긴 프레임 직전 왼쪽 둘레 점 — 그 한 걸음에 점이 어디서 어디로 튀었는지 줄로 보이려고
let gapFromX = [];
let gapFromY = [];
function controlPoint() { return [magnets[1][0].x, magnets[1][0].y]; }
// 자석 자리 — 반마다 같은 길(반 가운데 기준). 숨은 탭 뒤에는 HIDDEN_GAP_S 만큼 앞선 시각의 자리
function pathTime(t) { return t + (t >= HIDDEN_AT_S ? HIDDEN_GAP_S : 0); }
function place(t) {
for (let s = 0; s < 2; s += 1) {
if (handsOn) {
const lx = userX - CX[1];
magnets[s][0].x = CX[s] + clamp(lx, -W / 4 + 10, W / 4 - 10);
magnets[s][0].y = userY;
}
else {
const u = pathTime(t) / PATH_S * TAU;
magnets[s][0].x = CX[s] + Math.sin(u) * PATH_X;
magnets[s][0].y = CY + Math.sin(u * 2) * PATH_Y;
}
}
}
// 꼬리 — 자석에서 가장 먼 둘레 점까지 거리에서 처음 반지름을 뺀 값
function tailOf(r, m) {
let far = 0;
for (let i = 0; i < r.n; i += 1) {
far = Math.max(far, Math.hypot(r.x[i] - m.x, r.y[i] - m.y));
}
return far - PUDDLE_R;
}
function areaRatio(r) { return ringArea(r) / r.area0; }
// 터진 쪽 넓이 비 — 꼬인 다각형은 넓이가 음수로도 나오므로 크기만 정수로
function leftShown() { return Math.round(Math.abs(leftRatio)).toLocaleString('en-US'); }
function report() {
const t = Math.round(tailOf(rings[1], magnets[1][0]));
simStatus = gapDone
? fill(STATUS_SAFE, { l: leftShown(), r: areaRatio(rings[1]).toFixed(2), t: t })
: fill(STATUS_RUN, { t: t, r: areaRatio(rings[1]).toFixed(2) });
}
function reset() {
rings = [makeRing(CX[0], CY, PUDDLE_R, RING_POINTS), makeRing(CX[1], CY, PUDDLE_R, RING_POINTS)];
magnets = [[{ x: CX[0], y: CY, q: 1 }], [{ x: CX[1], y: CY, q: 1 }]];
burst = false;
gapDone = false;
leftRatio = 1;
place(0);
// 처음부터 자석 위에 앉은 모양에서 시작 — 짧게 미리 돌려 둔다
for (let k = 0; k < 60; k += 1) {
stepRingSafe(rings[0], magnets[0], PARAMS, 1 / 60);
stepRingSafe(rings[1], magnets[1], PARAMS, 1 / 60);
}
for (let s = 0; s < 2; s += 1) {
for (let i = 0; i < RING_POINTS; i += 1) {
rings[s].vx[i] = 0;
rings[s].vy[i] = 0;
}
}
report();
}
function stepBoth(dtLeft, dtRight) {
if (!burst) {
stepRingRaw(rings[0], magnets[0], PARAMS, dtLeft);
}
stepRingSafe(rings[1], magnets[1], PARAMS, dtRight);
}
function update(dt) {
const before = sceneT - dt;
place(sceneT);
if (!handsOn && !gapDone && before < HIDDEN_AT_S && sceneT >= HIDDEN_AT_S) {
// 긴 한 프레임 — 같은 HIDDEN_GAP_S 를 두 쪽에 넘긴다. 왼쪽은 그대로 한 걸음, 오른쪽은 잘라서 나눠 걷는다
gapFromX = rings[0].x.slice();
gapFromY = rings[0].y.slice();
stepBoth(HIDDEN_GAP_S, HIDDEN_GAP_S);
gapDone = true;
leftRatio = areaRatio(rings[0]);
// 터진 쪽은 그 모양에서 멈춰 둔다 — 다음 걸음부터는 값이 끝없이 커져 화면 밖으로 사라진다
burst = !(Math.abs(leftRatio - 1) < 0.5);
}
else {
stepBoth(dt, dt);
}
report();
}
function restPose() { sceneT = 0.9; }
function settleNow() {
place(sceneT);
for (let k = 0; k < 90; k += 1) {
stepBoth(1 / 60, 1 / 60);
}
for (let s = 0; s < 2; s += 1) {
for (let i = 0; i < RING_POINTS; i += 1) {
rings[s].vx[i] = 0;
rings[s].vy[i] = 0;
}
}
report();
}
// ── 05 그리기 — 반마다 접시 바탕·자석 자리 고리·검은 웅덩이(점 사이를 2차 곡선으로 잇는다)와 광택, 위쪽 이름표 ──
const LABEL_RAW = 'Raw dt in one step';
const LABEL_SAFE = 'Capped at {m} s, split into substeps';
const LABEL_GAP = '{g} s in one frame';
const LABEL_AREA = 'area ×{a}';
function puddlePath(r, smoothEdge) {
const n = r.n;
ctx.beginPath();
if (!smoothEdge) {
ctx.moveTo(r.x[0], r.y[0]);
for (let i = 1; i < n; i += 1) {
ctx.lineTo(r.x[i], r.y[i]);
}
ctx.closePath();
return;
}
// 점 i 를 조절점으로, 이웃 점과의 가운데들을 지나가게 — 점 고리가 매끈한 테두리가 된다
ctx.moveTo((r.x[n - 1] + r.x[0]) / 2, (r.y[n - 1] + r.y[0]) / 2);
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
ctx.quadraticCurveTo(r.x[i], r.y[i], (r.x[i] + r.x[j]) / 2, (r.y[i] + r.y[j]) / 2);
}
ctx.closePath();
}
function drawPuddle(s) {
const r = rings[s];
const broken = s === 0 && burst;
ctx.save();
ctx.beginPath();
ctx.rect(s * W / 2, 0, W / 2, H);
ctx.clip();
if (broken) {
// 튄 자리 — 긴 프레임 한 걸음에 둘레 점이 옮겨 간 줄(대부분 화면 밖까지)
for (let i = 0; i < r.n; i += 1) {
line(gapFromX[i], gapFromY[i], r.x[i], r.y[i], 1.6, CORAL_DEEP, 0.75);
}
for (let i = 0; i < r.n; i += 1) {
disc(gapFromX[i], gapFromY[i], 1.6, FLUID, 1);
}
}
puddlePath(r, !broken);
ctx.fillStyle = rgba(FLUID, broken ? 0.16 : 1);
ctx.fill('evenodd');
ctx.lineWidth = broken ? 1.2 : 1.6;
ctx.strokeStyle = rgba(broken ? CORAL_DEEP : FLUID_EDGE, 1);
ctx.stroke();
if (!broken) {
// 광택 — 웅덩이 무게중심에서 왼쪽 위로 비켜난 자리에 분홍, 오른쪽 아래에 하늘색 작은 반사
const c = ringCenter(r);
ctx.save();
puddlePath(r, true);
ctx.clip();
const g1 = ctx.createRadialGradient(c[0] - 16, c[1] - 18, 1, c[0] - 16, c[1] - 18, 14);
g1.addColorStop(0, rgba(LIGHT_PINK, 0.5));
g1.addColorStop(1, rgba(LIGHT_PINK, 0));
ctx.fillStyle = g1;
ctx.fillRect(c[0] - 60, c[1] - 60, 120, 120);
const g2 = ctx.createRadialGradient(c[0] + 20, c[1] + 14, 1, c[0] + 20, c[1] + 14, 10);
g2.addColorStop(0, rgba(LIGHT_SKY, 0.4));
g2.addColorStop(1, rgba(LIGHT_SKY, 0));
ctx.fillStyle = g2;
ctx.fillRect(c[0] - 60, c[1] - 60, 120, 120);
ctx.restore();
}
ctx.restore();
}
function draw() {
clearStage(DISH);
for (let s = 0; s < 2; s += 1) {
const m = magnets[s][0];
ctx.setLineDash([4, 4]);
ring(m.x, m.y, 16, 2, VIOLET_DEEP, 0.55);
ctx.setLineDash([]);
drawPuddle(s);
disc(m.x, m.y, 3, VIOLET_DEEP, 0.9);
}
line(W / 2, 0, W / 2, H, 3, CREAM, 1);
tag(LABEL_RAW, 14, 18, CORAL_DEEP, CREAM, 1, 10, 'left');
tag(fill(LABEL_SAFE, { m: PUDDLE_DT_MAX }), W - 14, 18, VIOLET_DEEP, CREAM, 1, 10, 'right');
if (gapDone) {
tag(fill(LABEL_GAP, { g: HIDDEN_GAP_S }), W / 2, 44, INK, CREAM, 1, 10, 'center');
tag(fill(LABEL_AREA, { a: leftShown() }), 14, 236, CORAL_DEEP, CREAM, 1, 10, 'left');
tag(fill(LABEL_AREA, { a: areaRatio(rings[1]).toFixed(2) }), W - 14, 236, VIOLET_DEEP, CREAM, 1, 10, 'right');
}
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
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();
if (handsOn) {
return;
}
const c = controlPoint();
userX = c[0];
userY = c[1];
handsOn = true;
}
// 입력 뒤 — 움직임 줄이기면 스프링 없이 바로 그 자리에. 루프가 멈춰 있으면(움직임 줄이기·Pause) 화면과 상태 줄을 한 번 바로 고친다
function afterInput() {
if (still) {
settleNow();
}
if (still || paused) {
draw();
pushStatus();
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 시연 중에는 한 바퀴(LOOP_S)를 넘으면 처음 상태로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (!handsOn && 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();
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();
if (!handsOn) {
sceneT = 0;
reset();
restPose();
}
settleNow();
draw();
pushStatus();
}
function play() {
if (!handsOn) {
sceneT = 0;
reset();
}
draw();
pushStatus();
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 });
bindInput(off.signal);
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PuddleDragSurfaceTension - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PuddleDragSurfaceTension.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PuddleDragSurfaceTension() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 계산 좌표 — 캔버스 안은 늘 480×300. 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
// 사람이 손댔는가 — 손대면 자석(또는 장면의 조절점)이 정해 둔 길 대신 userX·userY 를 따른다
let handsOn = false;
let userX = W / 2;
let userY = H / 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 을 부드러운 계단(3x² − 2x³)으로
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 1 − e^(−rate·dt) — 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 각을 −π ~ π 로 감는다 — 두 각의 차가 한 바퀴를 넘어 돌아가는 길을 고르지 않게
function wrapAngle(a: number): number {
let x = a % TAU;
if (x > Math.PI) { x -= TAU; }
if (x < -Math.PI) { x += TAU; }
return x;
}
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (32비트 자리 밀기 섞기 세 번)
function makeRng(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s ^= s << 13; s >>>= 0;
s ^= s >>> 17;
s ^= s << 5; s >>>= 0;
return s / 4294967296;
};
}
// 상태 줄 틀 채우기 — {이름} 자리에 값
function fill(t: string, v: { [k: string]: string | number }): string {
return t.replace(/\{(\w+)\}/g, function (m: string, k: string): string { return k in v ? String(v[k]) : m; });
}
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기고, 1/240초 잔걸음으로 나눠 돈다
type Spring = { x: number; v: number; to: number };
const SPRING_SUBSTEP = 1 / 240;
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
// 스프링을 그 값에 바로 앉힌다(속도 0) — 움직임 줄이기·첫 장면
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// 각도 스프링 — 목표와의 차를 −π ~ π 로 감은 뒤 같은 식으로
function stepAngleSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * wrapAngle(s.to - s.x) - damping * s.v) * h;
s.x = wrapAngle(s.x + s.v * h);
left -= h;
}
}
// ── 팔레트 (RGB) — 액체는 거의 검정(기능색), 조명·바탕·자기력선은 파스텔, 글자는 진한 색 ──
const PAPER: RGB = [250, 247, 242];
const DISH: RGB = [234, 238, 247];
const DISH_DEEP: RGB = [214, 220, 236];
const FLUID: RGB = [13, 12, 17];
const FLUID_EDGE: RGB = [86, 80, 112];
const LIGHT_PINK: RGB = [255, 190, 210];
const LIGHT_SKY: RGB = [172, 212, 255];
const LAVENDER: RGB = [198, 186, 238];
const MINT: RGB = [168, 222, 200];
const BUTTER: RGB = [248, 228, 162];
const POLE_N: RGB = [244, 180, 198];
const POLE_S: RGB = [170, 204, 240];
const INK: RGB = [38, 34, 50];
const INK_SOFT: RGB = [104, 98, 122];
const VIOLET_DEEP: RGB = [82, 58, 158];
const CORAL_DEEP: RGB = [174, 62, 52];
const CREAM: RGB = [255, 251, 244];
// ── 쌍극자 장 ───────────────────────────────────────────────
// 극 하나를 부호 있는 점처럼 두고, 극에서 재려는 점까지의 벡터 d = (dx, dy, soften) 로 B += q·d / |d|³ 을 극마다 더한다.
// soften 은 극과 표면 사이의 거리 — |d|² = dx² + dy² + soften² 이라 극 바로 위에서도 거리가 soften 아래로 내려가지 않는다.
// 세기 |B| = q·FIELD_REF_PX² / |d|² (거리 제곱에 반비례, 벡터 꼴로는 거리 세제곱 역수). FIELD_REF_PX 거리에서 극 하나의 세기가 1
type Pole = { x: number; y: number; q: number };
const FIELD_REF_PX = 60;
const field: number[] = [0, 0, 0];
function fieldAt(poles: Pole[], x: number, y: number, soften: number): number {
let bx = 0;
let by = 0;
let bz = 0;
const ref2 = FIELD_REF_PX * FIELD_REF_PX;
for (const p of poles) {
const dx = x - p.x;
const dy = y - p.y;
const r2 = dx * dx + dy * dy + soften * soften;
const k = p.q * ref2 / (r2 * Math.sqrt(r2));
bx += dx * k;
by += dy * k;
bz += soften * k;
}
field[0] = bx;
field[1] = by;
field[2] = bz;
return Math.sqrt(bx * bx + by * by + bz * bz);
}
// ── 엇갈린 세 물결 ──────────────────────────────────────────
// 방향이 0°·120°·240° 인 cos 물결 셋을 더하면(−1.5 ~ 3) 세 마루가 겹치는 점이 육각 배열로 놓인다.
// 마루 사이 거리 = spacing 이 되도록 물결수 k = 4π / (√3 · spacing). 합을 3 으로 나눈 값에서 HEX_FLOOR 아래는 잘라
// 가시 사이를 평평하게 두고, 남은 윗부분을 0~1 로 늘린다(꼭대기 쪽이 곧게 좁아지는 원뿔 모양)
const HEX_C = 0.5;
const HEX_S = Math.sqrt(3) / 2;
const HEX_FLOOR = 0.3;
function hexWave(x: number, y: number, spacing: number): number {
const k = 4 * Math.PI / (Math.sqrt(3) * spacing);
const s = Math.cos(k * x) + Math.cos(k * (-HEX_C * x + HEX_S * y)) + Math.cos(k * (-HEX_C * x - HEX_S * y));
return Math.max(0, (s / 3 - HEX_FLOOR) / (1 - HEX_FLOOR));
}
// ── 높이 격자 ───────────────────────────────────────────────
// 화면을 cell px 칸으로 나눈 낮은 해상도 격자. z = 높이(px), wet = 액체가 덮은 정도(0~1), amp·vel = 칸마다 가시 높이 스프링
type HeightGrid = { gw: number; gh: number; cell: number; ox: number; oy: number; z: Float32Array; wet: Float32Array; amp: Float32Array; vel: Float32Array; target: Float32Array; rgba: Uint8ClampedArray };
function makeGrid(ox: number, oy: number, w: number, h: number, cell: number): HeightGrid {
const gw = Math.ceil(w / cell) + 1;
const gh = Math.ceil(h / cell) + 1;
const n = gw * gh;
return { gw: gw, gh: gh, cell: cell, ox: ox, oy: oy, z: new Float32Array(n), wet: new Float32Array(n), amp: new Float32Array(n), vel: new Float32Array(n), target: new Float32Array(n), rgba: new Uint8ClampedArray(n * 4) };
}
// 칸마다 가시 높이 스프링을 목표(target)로 — 같은 반암시 적분, 1/240초 잔걸음
function stepGridSprings(g: HeightGrid, stiffness: number, damping: number, dt: number): void {
let left = dt;
const n = g.gw * g.gh;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
for (let i = 0; i < n; i += 1) {
g.vel[i] += (stiffness * (g.target[i] - g.amp[i]) - damping * g.vel[i]) * h;
g.amp[i] += g.vel[i] * h;
}
left -= h;
}
}
function settleGrid(g: HeightGrid): void {
g.amp.set(g.target);
g.vel.fill(0);
}
// ── 반사 하이라이트 ─────────────────────────────────────────
// 면의 방향 n ≈ (−∂z/∂x, −∂z/∂y, 1) 을 이웃 칸 높이 차로 구하고, 빛 방향과 눈 방향(정면 위)의 가운데 방향 h 와의 내적을
// SHINE 제곱해 하이라이트 세기로. 빛은 두 개(파스텔 두 색). 액체 바탕은 거의 검정이고 기울수록 가장자리 색을 조금 섞는다
type Light = { x: number; y: number; z: number; c: RGB; k: number };
type HalfVec = { x: number; y: number; z: number; c: RGB; k: number };
// 기울기 색 섞기 — 면이 기운 만큼(1 − n_z) × TILT_MIX 로 가장자리 색을 섞어 가시 옆면이 바탕 검정과 갈리게
const TILT_MIX = 0.5;
function halfVectors(lights: Light[]): HalfVec[] {
return lights.map(function (l: Light): HalfVec {
const ll = Math.hypot(l.x, l.y, l.z) || 1;
const hx = l.x / ll;
const hy = l.y / ll;
const hz = l.z / ll + 1;
const hl = Math.hypot(hx, hy, hz) || 1;
return { x: hx / hl, y: hy / hl, z: hz / hl, c: l.c, k: l.k };
});
}
// 한 점 칠하기 — 기울기(gx, gy)·덮인 정도 wet·바탕색 bg 로 out[o..o+3] 에 RGBA.
// bg 가 null 이면 바탕과 섞지 않고 액체 색에 투명도 wet 을 준다(아래 그림 위에 겹쳐 붙일 때)
function shadeOne(gx: number, gy: number, wet: number, bg: RGB | null, hv: HalfVec[], shine: number, out: Uint8ClampedArray, o: number): void {
const inv = 1 / Math.sqrt(gx * gx + gy * gy + 1);
const nx = -gx * inv;
const ny = -gy * inv;
const nz = inv;
const tilt = (1 - nz) * TILT_MIX;
let r = lerp(FLUID[0], FLUID_EDGE[0], tilt);
let g = lerp(FLUID[1], FLUID_EDGE[1], tilt);
let b = lerp(FLUID[2], FLUID_EDGE[2], tilt);
for (const h of hv) {
const d = nx * h.x + ny * h.y + nz * h.z;
if (d <= 0) { continue; }
const s = Math.pow(d, shine) * h.k;
r += h.c[0] * s;
g += h.c[1] * s;
b += h.c[2] * s;
}
const a = clamp(wet, 0, 1);
if (!bg) {
out[o] = Math.min(255, r);
out[o + 1] = Math.min(255, g);
out[o + 2] = Math.min(255, b);
out[o + 3] = a * 255;
return;
}
out[o] = lerp(bg[0], Math.min(255, r), a);
out[o + 1] = lerp(bg[1], Math.min(255, g), a);
out[o + 2] = lerp(bg[2], Math.min(255, b), a);
out[o + 3] = 255;
}
// 격자 전체 칠하기 — 칸 하나에 한 번. 끝 칸은 안쪽 이웃으로 한쪽 차
function shadeGrid(g: HeightGrid, bg: RGB | null, lights: Light[], shine: number): number {
const hv = halfVectors(lights);
const gw = g.gw;
const gh = g.gh;
const z = g.z;
let count = 0;
for (let j = 0; j < gh; j += 1) {
const ju = j > 0 ? j - 1 : j;
const jd = j < gh - 1 ? j + 1 : j;
for (let i = 0; i < gw; i += 1) {
const il = i > 0 ? i - 1 : i;
const ir = i < gw - 1 ? i + 1 : i;
const k = j * gw + i;
const gx = (z[j * gw + ir] - z[j * gw + il]) / ((ir - il) * g.cell);
const gy = (z[jd * gw + i] - z[ju * gw + i]) / ((jd - ju) * g.cell);
shadeOne(gx, gy, g.wet[k], bg, hv, shine, g.rgba, k * 4);
count += 1;
}
}
return count;
}
// ── 점 고리 웅덩이 ──────────────────────────────────────────
// 둘레를 점 n 개 고리로. 힘(가속) = 끌림 drag × ∇(세기²) + 장력 tension × TENSION_RATE × (이웃 두 점의 가운데 − 자기)
// + 넓이 보존 압력 pressure × (처음 넓이 − 지금 넓이) / 처음 넓이 × 바깥 방향 − 점성 viscosity × 속도
type Ring = { n: number; x: number[]; y: number[]; vx: number[]; vy: number[]; area0: number };
type RingParams = { tension: number; pressure: number; drag: number; viscosity: number; soften: number };
const TENSION_RATE = 900;
function makeRing(cx: number, cy: number, r: number, n: number): Ring {
const ring: Ring = { n: n, x: [], y: [], vx: [], vy: [], area0: 0 };
for (let i = 0; i < n; i += 1) {
const a = i / n * TAU;
ring.x.push(cx + Math.cos(a) * r);
ring.y.push(cy + Math.sin(a) * r);
ring.vx.push(0);
ring.vy.push(0);
}
ring.area0 = ringArea(ring);
return ring;
}
// 넓이 — 꼭짓점을 차례로 잇는 다각형의 넓이(두 점의 외적을 반씩 더함). makeRing 처럼 각도가 커지는 차례로 놓으면 양수
function ringArea(r: Ring): number {
let a = 0;
for (let i = 0; i < r.n; i += 1) {
const j = (i + 1) % r.n;
a += r.x[i] * r.y[j] - r.x[j] * r.y[i];
}
return a / 2;
}
function ringCenter(r: Ring): [number, number] {
let x = 0;
let y = 0;
for (let i = 0; i < r.n; i += 1) { x += r.x[i]; y += r.y[i]; }
return [x / r.n, y / r.n];
}
// 세기² 의 기울기 — 1px 떨어진 네 점의 차로
function strength2Grad(poles: Pole[], x: number, y: number, soften: number): [number, number] {
const l = fieldAt(poles, x - 1, y, soften);
const r = fieldAt(poles, x + 1, y, soften);
const u = fieldAt(poles, x, y - 1, soften);
const d = fieldAt(poles, x, y + 1, soften);
return [(r * r - l * l) / 2, (d * d - u * u) / 2];
}
const ringAx: number[] = [];
const ringAy: number[] = [];
function ringAccel(r: Ring, poles: Pole[], p: RingParams): void {
const n = r.n;
const area = ringArea(r);
const squeeze = p.pressure * (r.area0 - area) / Math.max(1, Math.abs(r.area0));
for (let i = 0; i < n; i += 1) {
const a = (i + n - 1) % n;
const b = (i + 1) % n;
const mx = (r.x[a] + r.x[b]) / 2;
const my = (r.y[a] + r.y[b]) / 2;
// 바깥 방향 — 앞뒤 점을 잇는 방향 (tx, ty) 를 (ty, −tx) 로 돌린 쪽. 넓이가 양수가 되는 차례로 놓인 고리에서는 이쪽이 바깥
const tx = r.x[b] - r.x[a];
const ty = r.y[b] - r.y[a];
const tl = Math.hypot(tx, ty) || 1;
const g = strength2Grad(poles, r.x[i], r.y[i], p.soften);
ringAx[i] = p.drag * g[0] + p.tension * TENSION_RATE * (mx - r.x[i]) + squeeze * (ty / tl) - p.viscosity * r.vx[i];
ringAy[i] = p.drag * g[1] + p.tension * TENSION_RATE * (my - r.y[i]) + squeeze * (-tx / tl) - p.viscosity * r.vy[i];
}
}
// 한 걸음 — 넘겨받은 dt 를 그대로 한 번에(속도 먼저, 위치 다음). 자르지도 나누지도 않는다
function stepRingRaw(r: Ring, poles: Pole[], p: RingParams, dt: number): void {
ringAccel(r, poles, p);
for (let i = 0; i < r.n; i += 1) {
r.vx[i] += ringAx[i] * dt;
r.vy[i] += ringAy[i] * dt;
r.x[i] += r.vx[i] * dt;
r.y[i] += r.vy[i] * dt;
}
}
// 나눠 걷기 — dt 를 PUDDLE_DT_MAX 에서 자르고, 그것을 PUDDLE_SUBSTEP 이하 잔걸음으로 쪼개 위 걸음을 되풀이
const PUDDLE_DT_MAX = 0.05;
const PUDDLE_SUBSTEP = 1 / 240;
function stepRingSafe(r: Ring, poles: Pole[], p: RingParams, dt: number): void {
let left = Math.min(dt, PUDDLE_DT_MAX);
while (left > 1e-9) {
const h = Math.min(PUDDLE_SUBSTEP, left);
stepRingRaw(r, poles, p, h);
left -= h;
}
}
// 고리 안을 높이 격자의 덮인 정도로 — 줄마다 다각형 변과 만나는 x 를 모아 짝수 번째와 홀수 번째 사이를 채운다
function ringToWet(r: Ring, g: HeightGrid, out: Float32Array): void {
out.fill(0);
const xs: number[] = [];
for (let j = 0; j < g.gh; j += 1) {
const y = g.oy + j * g.cell;
xs.length = 0;
for (let i = 0; i < r.n; i += 1) {
const k = (i + 1) % r.n;
const y0 = r.y[i];
const y1 = r.y[k];
if ((y0 <= y && y1 > y) || (y1 <= y && y0 > y)) {
xs.push(r.x[i] + (y - y0) / (y1 - y0) * (r.x[k] - r.x[i]));
}
}
xs.sort(function (a: number, b: number): number { return a - b; });
for (let m = 0; m + 1 < xs.length; m += 2) {
const i0 = Math.max(0, Math.ceil((xs[m] - g.ox) / g.cell));
const i1 = Math.min(g.gw - 1, Math.floor((xs[m + 1] - g.ox) / g.cell));
for (let i = i0; i <= i1; i += 1) { out[j * g.gw + i] = 1; }
}
}
}
// 덮인 정도를 이웃 평균으로 times 번 — 가장자리를 둥글게(돔 높이·부드러운 테두리)
function blurWet(g: HeightGrid, src: Float32Array, times: number, tmp: Float32Array): void {
for (let t = 0; t < times; t += 1) {
for (let j = 0; j < g.gh; j += 1) {
for (let i = 0; i < g.gw; i += 1) {
const k = j * g.gw + i;
const l = i > 0 ? src[k - 1] : 0;
const r = i < g.gw - 1 ? src[k + 1] : 0;
const u = j > 0 ? src[k - g.gw] : 0;
const d = j < g.gh - 1 ? src[k + g.gw] : 0;
tmp[k] = (src[k] * 2 + l + r + u + d) / 6;
}
}
src.set(tmp);
}
}
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';
// 화소 배율 — 캔버스 내부 크기에 곱한다. 2 에서 자른다(배율 3 기기에서 화소 수가 2.25배로 늘지 않게)
let dpr = 1;
function fitCanvas(): void {
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 rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(c[0]) + ',' + Math.round(c[1]) + ',' + Math.round(c[2]) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 격자 그림을 화면에 — 칸 하나 = 화소 하나인 숨은 캔버스에 putImageData 로 넣고, drawImage 로 판 크기까지 늘려 붙인다(부드럽게 섞으며)
type Blitter = (rgba: Uint8ClampedArray, gw: number, gh: number, x: number, y: number, w: number, h: number) => void;
function makeBlitter(): Blitter {
const off = document.createElement('canvas');
const offCtx = off.getContext('2d');
let img: ImageData | null = null;
return function (data: Uint8ClampedArray, gw: number, gh: number, x: number, y: number, w: number, h: number): void {
if (!offCtx) { return; }
if (!img || img.width !== gw || img.height !== gh) {
off.width = gw;
off.height = gh;
img = offCtx.createImageData(gw, gh);
}
img.data.set(data);
offCtx.putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(off, 0, 0, gw, gh, x, y, w, h);
};
}
// 높이 격자 한 장 — 칸 가운데가 표본 점이라 반 칸 바깥에서부터 늘린다
const blitMain = makeBlitter();
function drawGrid(g: HeightGrid): void {
blitMain(g.rgba, g.gw, g.gh, g.ox - g.cell / 2, g.oy - g.cell / 2, g.gw * g.cell, g.gh * g.cell);
}
// ── 그리기 도구 ─────────────────────────────────────────────
function clearStage(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px: number, weight: number): void { ctx.font = weight + ' ' + px.toFixed(1) + 'px ' + FONT_STACK; }
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
setFont(px, weight);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.arcTo(x + w, y, x + w, y + h, rr);
ctx.arcTo(x + w, y + h, x, y + h, rr);
ctx.arcTo(x, y + h, x, y, rr);
ctx.arcTo(x, y, x + w, y, rr);
ctx.closePath();
}
function fillRound(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; }
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
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 line(x0: number, y0: number, x1: number, y1: number, width: number, c: RGB, a: number): void {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 이름표 — 둥근 바탕 위 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, a: number, px: number, align: CanvasTextAlign): void {
setFont(px, 700);
const w = ctx.measureText(s).width + px * 1.2;
const x0 = align === 'left' ? x : (align === 'right' ? x - w : x - w / 2);
fillRound(x0, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x0 + w / 2, y + 0.5, fg, a, px, 700, 'center');
}
// 자석 막대 — 가운데 (x, y), 방향 각 a, 반 길이 half. 앞쪽(N) 분홍·뒤쪽(S) 하늘색 두 칸
function drawBarMagnet(x: number, y: number, a: number, half: number, thick: number, alpha: number): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(a);
ctx.globalAlpha = alpha;
fillRound(-half, -thick / 2, half, thick, thick * 0.3, POLE_S, 1);
fillRound(0, -thick / 2, half, thick, thick * 0.3, POLE_N, 1);
roundRectPath(-half, -thick / 2, half * 2, thick, thick * 0.3);
ctx.lineWidth = 1.4;
ctx.strokeStyle = rgba(INK, 0.75);
ctx.stroke();
label('N', half * 0.5, 0.5, INK, 0.9, thick * 0.62, 800, 'center');
label('S', -half * 0.5, 0.5, INK, 0.9, thick * 0.62, 800, 'center');
ctx.restore();
}
// ── 사람 손 — 포인터 끌기와 키 ──────────────────────────────
// 캔버스를 누른 채 움직이면 그 자리가 조절점(userX, userY)이 된다. 키보드는 캔버스에 초점이 있을 때만:
// 화살표 8px(Shift 24px) 옮기기 · Enter·Space 가운데로 되돌리기. 항목이 onNudge·onPoint 를 바꿔 끼울 수 있다
let pressing = false;
function eventPoint(e: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [(e.clientX - r.left) * W / Math.max(1, r.width), (e.clientY - r.top) * H / Math.max(1, r.height)];
}
let onPoint: (x: number, y: number) => void = function (x: number, y: number): void {
userX = clamp(x, 0, W);
userY = clamp(y, 0, H);
};
let onNudge: (dx: number, dy: number, center: boolean) => void = function (dx: number, dy: number, center: boolean): void {
if (center) { userX = W / 2; userY = H / 2; return; }
userX = clamp(userX + dx, 0, W);
userY = clamp(userY + dy, 0, H);
};
function bindInput(signal: AbortSignal): void {
cv.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
pressing = true;
cv.setPointerCapture(e.pointerId);
const p = eventPoint(e);
onPoint(p[0], p[1]);
afterInput();
}, { signal: signal });
cv.addEventListener('pointermove', function (e: PointerEvent): void {
if (!pressing) { return; }
const p = eventPoint(e);
onPoint(p[0], p[1]);
if (still || paused) { afterInput(); }
}, { signal: signal });
const end = function (): void { pressing = false; };
cv.addEventListener('pointerup', end, { signal: signal });
cv.addEventListener('pointercancel', end, { signal: signal });
cv.addEventListener('keydown', function (e: KeyboardEvent): void {
const step = e.shiftKey ? 24 : 8;
let dx = 0;
let dy = 0;
let center = false;
if (e.key === 'ArrowLeft') { dx = -step; }
else if (e.key === 'ArrowRight') { dx = step; }
else if (e.key === 'ArrowUp') { dy = -step; }
else if (e.key === 'ArrowDown') { dy = step; }
else if (e.key === 'Enter' || e.key === ' ') { center = true; }
else { return; }
e.preventDefault();
takeOver();
onNudge(dx, dy, center);
afterInput();
}, { signal: signal });
}
// ── 05 자석을 따라 끌려가며 둥글게 버티는 웅덩이 ─────────────
// 웅덩이 둘 — 왼쪽 반과 오른쪽 반에 같은 웅덩이·같은 자석 길. 다른 것은 긴 한 프레임을 받는 방법 하나
// 왼쪽: 넘겨받은 dt 를 그대로 한 걸음(stepRingRaw) · 오른쪽: PUDDLE_DT_MAX 에서 자르고 잔걸음으로 나눔(stepRingSafe)
const SURFACE_TENSION = 0.22;
const AREA_PRESSURE = 20000;
const DRAG_GAIN = 24000;
const VISCOSITY = 4;
const MAG_DEPTH_PX = 40;
const RING_POINTS = 40;
const PUDDLE_R = 46;
// 자석 길 — 반 가운데 둘레를 가로 ± PATH_X, 세로 ± PATH_Y 로 PATH_S 초에 한 바퀴(옆으로 누운 8자)
const PATH_X = 50;
const PATH_Y = 30;
const PATH_S = 3;
// 숨은 탭 흉내 — HIDDEN_AT_S 초에 한 번, HIDDEN_GAP_S 초가 한 프레임에 몰려 들어온다(자석은 그만큼 앞서 간 자리로)
const HIDDEN_AT_S = 1.15;
const HIDDEN_GAP_S = 0.8;
const LOOP_S = 3.4;
const STATUS_SAFE = '긴 프레임 뒤 넓이 — 그대로 ×{l} · 자름 ×{r} · 꼬리 {t}px';
const STATUS_RUN = '긴 프레임 전 — 꼬리 {t}px · 넓이 ×{r}';
const PARAMS: RingParams = { tension: SURFACE_TENSION, pressure: AREA_PRESSURE, drag: DRAG_GAIN, viscosity: VISCOSITY, soften: MAG_DEPTH_PX };
const CX = [W / 4, W * 3 / 4];
const CY = H / 2 - 6;
let rings: Ring[] = [];
let magnets: Pole[][] = [];
let burst = false;
let gapDone = false;
let leftRatio = 1;
// 긴 프레임 직전 왼쪽 둘레 점 — 그 한 걸음에 점이 어디서 어디로 튀었는지 줄로 보이려고
let gapFromX: number[] = [];
let gapFromY: number[] = [];
function controlPoint(): [number, number] { return [magnets[1][0].x, magnets[1][0].y]; }
// 자석 자리 — 반마다 같은 길(반 가운데 기준). 숨은 탭 뒤에는 HIDDEN_GAP_S 만큼 앞선 시각의 자리
function pathTime(t: number): number { return t + (t >= HIDDEN_AT_S ? HIDDEN_GAP_S : 0); }
function place(t: number): void {
for (let s = 0; s < 2; s += 1) {
if (handsOn) {
const lx = userX - CX[1];
magnets[s][0].x = CX[s] + clamp(lx, -W / 4 + 10, W / 4 - 10);
magnets[s][0].y = userY;
} else {
const u = pathTime(t) / PATH_S * TAU;
magnets[s][0].x = CX[s] + Math.sin(u) * PATH_X;
magnets[s][0].y = CY + Math.sin(u * 2) * PATH_Y;
}
}
}
// 꼬리 — 자석에서 가장 먼 둘레 점까지 거리에서 처음 반지름을 뺀 값
function tailOf(r: Ring, m: Pole): number {
let far = 0;
for (let i = 0; i < r.n; i += 1) { far = Math.max(far, Math.hypot(r.x[i] - m.x, r.y[i] - m.y)); }
return far - PUDDLE_R;
}
function areaRatio(r: Ring): number { return ringArea(r) / r.area0; }
// 터진 쪽 넓이 비 — 꼬인 다각형은 넓이가 음수로도 나오므로 크기만 정수로
function leftShown(): string { return Math.round(Math.abs(leftRatio)).toLocaleString('en-US'); }
function report(): void {
const t = Math.round(tailOf(rings[1], magnets[1][0]));
simStatus = gapDone
? fill(STATUS_SAFE, { l: leftShown(), r: areaRatio(rings[1]).toFixed(2), t: t })
: fill(STATUS_RUN, { t: t, r: areaRatio(rings[1]).toFixed(2) });
}
function reset(): void {
rings = [makeRing(CX[0], CY, PUDDLE_R, RING_POINTS), makeRing(CX[1], CY, PUDDLE_R, RING_POINTS)];
magnets = [[{ x: CX[0], y: CY, q: 1 }], [{ x: CX[1], y: CY, q: 1 }]];
burst = false;
gapDone = false;
leftRatio = 1;
place(0);
// 처음부터 자석 위에 앉은 모양에서 시작 — 짧게 미리 돌려 둔다
for (let k = 0; k < 60; k += 1) {
stepRingSafe(rings[0], magnets[0], PARAMS, 1 / 60);
stepRingSafe(rings[1], magnets[1], PARAMS, 1 / 60);
}
for (let s = 0; s < 2; s += 1) {
for (let i = 0; i < RING_POINTS; i += 1) { rings[s].vx[i] = 0; rings[s].vy[i] = 0; }
}
report();
}
function stepBoth(dtLeft: number, dtRight: number): void {
if (!burst) { stepRingRaw(rings[0], magnets[0], PARAMS, dtLeft); }
stepRingSafe(rings[1], magnets[1], PARAMS, dtRight);
}
function update(dt: number): void {
const before = sceneT - dt;
place(sceneT);
if (!handsOn && !gapDone && before < HIDDEN_AT_S && sceneT >= HIDDEN_AT_S) {
// 긴 한 프레임 — 같은 HIDDEN_GAP_S 를 두 쪽에 넘긴다. 왼쪽은 그대로 한 걸음, 오른쪽은 잘라서 나눠 걷는다
gapFromX = rings[0].x.slice();
gapFromY = rings[0].y.slice();
stepBoth(HIDDEN_GAP_S, HIDDEN_GAP_S);
gapDone = true;
leftRatio = areaRatio(rings[0]);
// 터진 쪽은 그 모양에서 멈춰 둔다 — 다음 걸음부터는 값이 끝없이 커져 화면 밖으로 사라진다
burst = !(Math.abs(leftRatio - 1) < 0.5);
} else {
stepBoth(dt, dt);
}
report();
}
function restPose(): void { sceneT = 0.9; }
function settleNow(): void {
place(sceneT);
for (let k = 0; k < 90; k += 1) { stepBoth(1 / 60, 1 / 60); }
for (let s = 0; s < 2; s += 1) {
for (let i = 0; i < RING_POINTS; i += 1) { rings[s].vx[i] = 0; rings[s].vy[i] = 0; }
}
report();
}
// ── 05 그리기 — 반마다 접시 바탕·자석 자리 고리·검은 웅덩이(점 사이를 2차 곡선으로 잇는다)와 광택, 위쪽 이름표 ──
const LABEL_RAW = 'dt 그대로 한 걸음';
const LABEL_SAFE = '{m}초에서 자르고 나눠 걷기';
const LABEL_GAP = '{g}초가 한 프레임에';
const LABEL_AREA = '넓이 ×{a}';
function puddlePath(r: Ring, smoothEdge: boolean): void {
const n = r.n;
ctx.beginPath();
if (!smoothEdge) {
ctx.moveTo(r.x[0], r.y[0]);
for (let i = 1; i < n; i += 1) { ctx.lineTo(r.x[i], r.y[i]); }
ctx.closePath();
return;
}
// 점 i 를 조절점으로, 이웃 점과의 가운데들을 지나가게 — 점 고리가 매끈한 테두리가 된다
ctx.moveTo((r.x[n - 1] + r.x[0]) / 2, (r.y[n - 1] + r.y[0]) / 2);
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
ctx.quadraticCurveTo(r.x[i], r.y[i], (r.x[i] + r.x[j]) / 2, (r.y[i] + r.y[j]) / 2);
}
ctx.closePath();
}
function drawPuddle(s: number): void {
const r = rings[s];
const broken = s === 0 && burst;
ctx.save();
ctx.beginPath();
ctx.rect(s * W / 2, 0, W / 2, H);
ctx.clip();
if (broken) {
// 튄 자리 — 긴 프레임 한 걸음에 둘레 점이 옮겨 간 줄(대부분 화면 밖까지)
for (let i = 0; i < r.n; i += 1) { line(gapFromX[i], gapFromY[i], r.x[i], r.y[i], 1.6, CORAL_DEEP, 0.75); }
for (let i = 0; i < r.n; i += 1) { disc(gapFromX[i], gapFromY[i], 1.6, FLUID, 1); }
}
puddlePath(r, !broken);
ctx.fillStyle = rgba(FLUID, broken ? 0.16 : 1);
ctx.fill('evenodd');
ctx.lineWidth = broken ? 1.2 : 1.6;
ctx.strokeStyle = rgba(broken ? CORAL_DEEP : FLUID_EDGE, 1);
ctx.stroke();
if (!broken) {
// 광택 — 웅덩이 무게중심에서 왼쪽 위로 비켜난 자리에 분홍, 오른쪽 아래에 하늘색 작은 반사
const c = ringCenter(r);
ctx.save();
puddlePath(r, true);
ctx.clip();
const g1 = ctx.createRadialGradient(c[0] - 16, c[1] - 18, 1, c[0] - 16, c[1] - 18, 14);
g1.addColorStop(0, rgba(LIGHT_PINK, 0.5));
g1.addColorStop(1, rgba(LIGHT_PINK, 0));
ctx.fillStyle = g1;
ctx.fillRect(c[0] - 60, c[1] - 60, 120, 120);
const g2 = ctx.createRadialGradient(c[0] + 20, c[1] + 14, 1, c[0] + 20, c[1] + 14, 10);
g2.addColorStop(0, rgba(LIGHT_SKY, 0.4));
g2.addColorStop(1, rgba(LIGHT_SKY, 0));
ctx.fillStyle = g2;
ctx.fillRect(c[0] - 60, c[1] - 60, 120, 120);
ctx.restore();
}
ctx.restore();
}
function draw(): void {
clearStage(DISH);
for (let s = 0; s < 2; s += 1) {
const m = magnets[s][0];
ctx.setLineDash([4, 4]);
ring(m.x, m.y, 16, 2, VIOLET_DEEP, 0.55);
ctx.setLineDash([]);
drawPuddle(s);
disc(m.x, m.y, 3, VIOLET_DEEP, 0.9);
}
line(W / 2, 0, W / 2, H, 3, CREAM, 1);
tag(LABEL_RAW, 14, 18, CORAL_DEEP, CREAM, 1, 10, 'left');
tag(fill(LABEL_SAFE, { m: PUDDLE_DT_MAX }), W - 14, 18, VIOLET_DEEP, CREAM, 1, 10, 'right');
if (gapDone) {
tag(fill(LABEL_GAP, { g: HIDDEN_GAP_S }), W / 2, 44, INK, CREAM, 1, 10, 'center');
tag(fill(LABEL_AREA, { a: leftShown() }), 14, 236, CORAL_DEEP, CREAM, 1, 10, 'left');
tag(fill(LABEL_AREA, { a: areaRatio(rings[1]).toFixed(2) }), W - 14, 236, VIOLET_DEEP, CREAM, 1, 10, 'right');
}
}
// 상태 줄 — 글자가 바뀔 때만 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
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();
if (handsOn) { return; }
const c = controlPoint();
userX = c[0];
userY = c[1];
handsOn = true;
}
// 입력 뒤 — 움직임 줄이기면 스프링 없이 바로 그 자리에. 루프가 멈춰 있으면(움직임 줄이기·일시정지) 화면과 상태 줄을 한 번 바로 고친다
function afterInput(): void {
if (still) { settleNow(); }
if (still || paused) { draw(); pushStatus(); }
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 시연 중에는 한 바퀴(LOOP_S)를 넘으면 처음 상태로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (!handsOn && 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();
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();
if (!handsOn) {
sceneT = 0;
reset();
restPose();
}
settleNow();
draw();
pushStatus();
}
function play(): void {
if (!handsOn) {
sceneT = 0;
reset();
}
draw();
pushStatus();
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 });
bindInput(off.signal);
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("ff") + " is-demo"} ref={rootRef}>
<canvas className={cx("ff__canvas")} data-role="canvas" width={480} height={300} role="img" tabIndex={0} aria-label={'빠르게 움직이는 자석을 따라 끌려가는 검은 웅덩이 두 개를 나란히 두고, 긴 한 프레임이 들어왔을 때 시간을 자르지 않은 왼쪽만 터지는 판. 화살표 키로 자석을 옮길 수 있다'}></canvas>
<p className={cx("ff__status")} data-role="status" aria-live="off">{'웅덩이가 끌려가는 중'}</p>
<button className={cx("ff__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 A Puddle Dragged by the Magnet While Tension Keeps It Round — Measured area 30.456% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Ferrofluid Simulation: 9 Canvas Scenes with Spikes
The post this demo lives in
Ferrofluid Simulation: 9 Canvas Scenes with SpikesFerrofluid simulation: A puddle stays flat as a magnet nears, then all spikes rise at once past a threshold. How? 02 sta See all demos in this post
If you like this effect - similar demos
Interactions04 An Arrow That Keeps Pointing at the Anchor While the Body ShiftsCSS Popover: 9 Ways to Anchor, Flip and Spring
Interactions01 A Basic Burst Whose Grains Spread Evenly Over a Sphere While Air Drag Eats Their SpeedCSS Fireworks: 9 Bursts, Trails and Reflections
Interactions04 3D Gears That Mesh at Their Tooth Ratio While the Pointer Moves the ViewJavaScript 3D Animation: 9 Pointer-Led Models