06Twin Bubbles That Join and Share One Curved Wall Between Them
When a small and a large bubble touch, the shared wall's curvature comes from the difference of their reciprocal radii, and the bubbles are drawn to the center distance at which the three films meet at 120 degrees. The wall is an arc centered on the small bubble's side, so it bulges into the larger one.
Best forComparison cards and two-item link scenes
R_SMALLR_LARGEJOIN_MS

<main class="stage">
<div class="hs is-demo" id="hs">
<canvas class="hs__canvas" data-role="canvas" width="480" height="300" tabindex="0" aria-label="Twin bubbles where a small and a large bubble join, a wall bulging into the larger one forms between them, and the angles where three films meet are measured. Press to part and rejoin them"></canvas>
<p class="hs__status" data-role="status" aria-live="off">Two bubbles approaching</p>
<div class="hs__tools">
<button class="hs__btn" data-role="size" type="button">Change size</button>
<button class="hs__btn" data-role="join" type="button">Part and rejoin</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.hs {
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-ink;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-control;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
// 판은 오른쪽에 있다 — 버튼은 왼쪽 위 칸에 두고, 좁으면 줄을 바꿔 판을 덮지 않는다
.hs__tools {
position: absolute;
left: $sp-3;
top: $sp-3;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: $sp-2;
}
.hs__btn {
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color $dur-quick ease-out;
}
.hs__btn:hover {
background: $subject-cream;
}
.hs__btn[aria-pressed="true"] {
background: $subject-mint;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, .95);
outline-offset: 2px;
}
// 손가락 가로 끌기는 이 캔버스가 받고, 세로 쓸기는 브라우저에 남겨 페이지 스크롤이 막히지 않게 한다
.hs__canvas { touch-action: pan-y; }
@media (max-width: 340px) {
.hs__btn { font-size: 10px; padding: $sp-1 $sp-2; }
.hs__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.hs__btn { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.hs {
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: #17141a;
}
.hs__canvas {
display: block;
width: 100%;
height: 100%;
cursor: crosshair;
}
.hs__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.hs__status {
position: absolute;
left: 12px;
bottom: 12px;
box-sizing: border-box;
max-width: 34%;
margin: 0;
padding: 4px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 11px;
line-height: 1.4;
pointer-events: none;
}
.hs__tools {
position: absolute;
left: 12px;
top: 12px;
box-sizing: border-box;
max-width: 34%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hs__btn {
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font: inherit;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background-color 220ms ease-out;
}
.hs__btn:hover {
background: #fff7e6;
}
.hs__btn[aria-pressed=true] {
background: #4cd4a6;
}
.hs__btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.95);
outline-offset: 2px;
}
.hs__canvas {
touch-action: pan-y;
}
@media (max-width: 340px) {
.hs__btn {
font-size: 10px;
padding: 4px 8px;
}
.hs__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.hs__btn {
transition: none;
}
}(function () {
const root = document.getElementById("hs");
if (!root) return;
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막을 얹어 명도 차로 방울이 읽힌다 (막의 무지갯빛은 이 표가 아니라 두께 계산에서 나온다)
const BG = [29, 26, 44];
const DEEP = [44, 39, 64];
const PAPER = [255, 248, 234];
const INK = [58, 48, 76];
const MINT = [126, 226, 192];
const SKY = [124, 196, 255];
const LILAC = [190, 164, 255];
const PINK = [255, 160, 190];
const PEACH = [255, 208, 168];
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
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 mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
const canvasEl = root.querySelector('[data-role="canvas"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const status = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text) {
if (status.textContent !== text) {
status.textContent = text;
}
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const 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 onResize() {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev) {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 장면이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row, label, value) {
const y = 112 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage() {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 2D 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값(−1~1)을 두고 그 사이를 부드러운 곡선으로 잇는다
const NOISE_SEED = 465;
const noisePerm = new Uint8Array(512);
const noiseVal = new Float32Array(256);
{
const nr = makeRng(NOISE_SEED);
for (let i = 0; i < 256; i += 1) {
noisePerm[i] = i;
noiseVal[i] = nr() * 2 - 1;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(nr() * (i + 1));
const t = noisePerm[i];
noisePerm[i] = noisePerm[j];
noisePerm[j] = t;
}
for (let i = 0; i < 256; i += 1) {
noisePerm[i + 256] = noisePerm[i];
}
}
function noiseHash(ix, iy) {
return noiseVal[noisePerm[noisePerm[ix & 255] + (iy & 255)]];
}
function noise2(x, y) {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = noiseHash(xi, yi);
const b = noiseHash(xi + 1, yi);
const c = noiseHash(xi, yi + 1);
const d = noiseHash(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 막의 굴절률 — 비눗물 막을 물과 같은 값으로 둔다
const FILM_N = 1.33;
// 세 파장(nm) — 화면의 빨강·초록·파랑 채널을 이 파장 하나씩으로 대신한다
const WAVE_RGB_NM = [650, 530, 450];
// 표를 만들 두께 범위(nm) — 1nm 눈금마다 세 채널 값을 미리 구해 둔다
const LUT_D_MAX = 1600;
const filmLut = new Float32Array((LUT_D_MAX + 1) * 3);
// 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
// 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
function reflectRGB(dNm) {
const out = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
out[c] = s * s;
}
return out;
}
for (let d = 0; d <= LUT_D_MAX; d += 1) {
const r = reflectRGB(d);
filmLut[d * 3] = r[0];
filmLut[d * 3 + 1] = r[1];
filmLut[d * 3 + 2] = r[2];
}
// 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
function insideCos(cosView) {
const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
return Math.sqrt(1 - s2);
}
// 두께 d(nm) 에서 채널 c 의 몫 — 표를 1nm 사이에서 선형 보간해 읽는다
function filmAt(dNm, c) {
const d = clamp(dNm, 0, LUT_D_MAX - 1);
const i = Math.floor(d);
const f = d - i;
return filmLut[i * 3 + c] * (1 - f) + filmLut[(i + 1) * 3 + c] * f;
}
// 캔버스 선·면에 쓸 막 색 — 어두운 무대 위에 세 채널 몫을 밝기로 얹는다
function filmColor(dNm, gain) {
return [BG[0] + filmAt(dNm, 0) * 235 * gain, BG[1] + filmAt(dNm, 1) * 235 * gain, BG[2] + filmAt(dNm, 2) * 235 * gain];
}
// 화소 하나를 막 색으로 — under 는 방울 뒤 바탕색, nx·ny·z 는 구면 위 법선(z 는 시선 쪽 성분)
function shadeFilm(px, o, dNm, nx, ny, z, under, alpha) {
const dEff = dNm * insideCos(z);
// 가장자리일수록 시선이 막을 비스듬히 지나 색이 진하다 — 가운데는 뒤 바탕이 비쳐 보이게 낮춘다
const amp = 0.22 + 0.78 * Math.pow(1 - z, 0.6);
// 왼쪽 위에서 오는 빛 하나의 반짝임
const hl = Math.pow(Math.max(0, nx * -0.42 + ny * -0.5 + z * 0.76), 60);
for (let c = 0; c < 3; c += 1) {
const v = under[c] + filmAt(dEff, c) * 235 * amp + hl * 220;
px[o + c] = under[c] + (v - under[c]) * alpha;
}
px[o + 3] = 255;
}
function makePix(w, h) {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const pc = c.getContext('2d');
if (!pc) {
return null;
}
const img = pc.createImageData(w, h);
return { cvs: c, pc: pc, img: img, px: img.data, w: w, h: h };
}
// putImageData 는 캔버스 변환(배율)을 무시한다 — 작은 캔버스에 올린 뒤 drawImage 로 장면 좌표에 그린다
function showPix(p, x, y) {
p.pc.putImageData(p.img, 0, 0);
ctx.drawImage(p.cvs, x, y, p.w, p.h);
}
// 06 — 작은 방울과 큰 방울이 붙으면 반지름 역수의 차로 사이 막의 휘어짐을 정하고, 세 막이 120도로 만나는 거리에 자리 잡는다
// 작은 방울·Large bubble radius(px)
const R_SMALL = 52;
const R_LARGE = 84;
// 닿은 뒤 자리를 잡기까지(ms)
const JOIN_MS = 900;
// 떨어져 있을 때 두 막 사이 틈(px)과 다가가기·떨어지기 시간(ms)
const GAP = 20;
const APPROACH_MS = 520;
const PART_MS = 360;
// 시연에서 붙은 채 머무는 시간(ms)
const HOLD_MS = 1100;
// Change size 버튼이 차례로 고르는 Small bubble radius
const SIZE_STEPS = [52, 38, 66, 84];
const MIDX = 322;
const MIDY = 150;
const X0 = 140;
const BW = 340;
const Y0 = 64;
const BH = 172;
const TXT_APPROACH = 'Two bubbles approaching';
const TXT_JOIN = 'Shared wall forming where they touch';
const TXT_JOINED = 'Three films meet at 120 degrees';
const TXT_PART = 'Parting';
const TXT_APART = 'Apart';
const sizeBtn = root.querySelector('[data-role="size"]');
const joinBtn = root.querySelector('[data-role="join"]');
const pix = makePix(BW, BH);
const under = [BG[0], BG[1], BG[2]];
let rs = R_SMALL;
const rl = R_LARGE;
let sizeIdx = 0;
// 0 다가감 · 1 붙는 중 · 2 붙음 · 3 Parting · 4 떨어져 Pause(움직임 줄이기)
let phase = 0;
let phaseMs = 0;
let tSec = 0;
// 세 막이 120도로 만나는 중심 거리 — 두 반지름이 60도로 벌어진 삼각형의 세 번째 변
function joinedDistance() { return Math.sqrt(rs * rs + rl * rl - rs * rl); }
function geometry() {
const deq = joinedDistance();
const touch = rs + rl;
const apart = touch + GAP;
let s = deq;
let p = 1;
if (phase === 0) {
s = lerp(apart, touch, smooth(phaseMs / APPROACH_MS));
p = 0;
}
else if (phase === 1) {
p = smooth(phaseMs / JOIN_MS);
s = lerp(touch, deq, p);
}
else if (phase === 3) {
p = 1 - smooth(phaseMs / PART_MS);
s = lerp(deq, apart, 1 - p);
}
else if (phase === 4) {
s = apart;
p = 0;
}
const x1 = MIDX + (rs - rl - s) / 2;
const g = { x1: x1, x2: x1 + s, s: s, touching: s < touch - 0.01, hj: 0, xc: 0, flat: true, rw: 0, xw: 0, p: p };
if (g.touching) {
// 두 원의 교점 — 작은 원 중심에서 공통 현까지 거리 a, 현의 반 길이 hj
const a = (s * s + rs * rs - rl * rl) / (2 * s);
g.hj = Math.sqrt(Math.max(rs * rs - a * a, 0));
g.xc = x1 + a;
// 사이 막의 휘어짐 = p × (1/작은 반지름 − 1/큰 반지름). 다 붙으면(p=1) 두 방울 압력 차와 맞는 값
const kappa = p * (1 / rs - 1 / rl);
if (kappa > 1e-6) {
g.flat = false;
g.rw = 1 / kappa;
// 사이 막 원의 중심은 작은 방울 쪽 — 그래서 막은 큰 방울 쪽으로 불룩하다
g.xw = g.xc - Math.sqrt(Math.max(g.rw * g.rw - g.hj * g.hj, 0));
}
}
return g;
}
// 위쪽 교점에서 세 막의 접선 사이 각(도) — 작은·큰 바깥 막, 큰 막·사이 막, 사이 막·작은 막
function tangentAway(cx, rx, ry, ox, orad) {
const len = Math.hypot(rx - cx, ry - MIDY);
let tx = -(ry - MIDY) / len;
let ty = (rx - cx) / len;
const qx = rx + tx * 2 - ox;
const qy = ry + ty * 2 - MIDY;
if (qx * qx + qy * qy < orad * orad) {
tx = -tx;
ty = -ty;
}
return [tx, ty];
}
function junctionAngles(g) {
const jx = g.xc;
const jy = MIDY - g.hj;
const t1 = tangentAway(g.x1, jx, jy, g.x2, rl);
const t2 = tangentAway(g.x2, jx, jy, g.x1, rs);
let t3 = [0, 1];
if (!g.flat) {
const len = Math.hypot(jx - g.xw, jy - MIDY);
t3 = [-(jy - MIDY) / len, (jx - g.xw) / len];
if (t3[1] < 0) {
t3 = [-t3[0], -t3[1]];
}
}
const ang = function (a, b) {
return Math.acos(clamp(a[0] * b[0] + a[1] * b[1], -1, 1)) * 180 / Math.PI;
};
return [ang(t1, t2), ang(t2, t3), ang(t3, t1)];
}
function paint(g) {
if (!pix) {
return;
}
const px = pix.px;
for (let j = 0; j < BH; j += 1) {
const y = Y0 + j + 0.5;
const dy = y - MIDY;
for (let i = 0; i < BW; i += 1) {
const x = X0 + i + 0.5;
const o = (j * BW + i) * 4;
const d1 = Math.hypot(x - g.x1, dy);
const d2 = Math.hypot(x - g.x2, dy);
const in1 = d1 < rs;
const in2 = d2 < rl;
if (!in1 && !in2) {
px[o + 3] = 0;
continue;
}
let small = in1;
if (g.touching) {
small = g.flat ? x < g.xc : Math.hypot(x - g.xw, dy) < g.rw;
}
const cx = small ? g.x1 : g.x2;
const rr = small ? rs : rl;
const nx = (x - cx) / rr;
const ny = dy / rr;
const z = Math.sqrt(Math.max(1 - nx * nx - ny * ny, 0));
const base = small ? 300 : 520;
const d = base * (1 + 0.3 * ny) + (small ? 70 : 90) * noise2(nx * 1.6 + tSec * 0.2 + (small ? 0 : 5), ny * 1.6);
shadeFilm(px, o, Math.max(d, 0), nx, ny, z, under, 1);
// 바깥 테두리 한 화소는 반쯤 비친다
px[o + 3] = 255 * clamp(Math.max(in1 ? rs - d1 : 0, in2 ? rl - d2 : 0), 0, 1);
}
}
showPix(pix, X0, Y0);
}
function report() {
if (phase === 0) {
say(TXT_APPROACH);
}
else if (phase === 1) {
say(TXT_JOIN);
}
else if (phase === 2) {
say(TXT_JOINED);
}
else if (phase === 3) {
say(TXT_PART);
}
else {
say(TXT_APART);
}
}
function step() {
tSec += FIXED_DT / 1000;
phaseMs += FIXED_DT;
if (phase === 0 && phaseMs >= APPROACH_MS) {
phase = 1;
phaseMs = 0;
}
else if (phase === 1 && phaseMs >= JOIN_MS) {
phase = 2;
phaseMs = 0;
}
else if (phase === 3 && phaseMs >= PART_MS) {
if (demo) {
sizeIdx = (sizeIdx + 1) % SIZE_STEPS.length;
rs = SIZE_STEPS[sizeIdx];
}
phase = 0;
phaseMs = 0;
}
report();
}
function settle() {
if (phase !== 4) {
phase = 2;
}
phaseMs = 0;
report();
}
function demoTick(t) {
if (t >= 0 && phase === 2 && phaseMs >= HOLD_MS) {
phase = 3;
phaseMs = 0;
}
}
function draw() {
clearStage();
const g = geometry();
ctx.fillStyle = rgba(DEEP, 0.9);
ctx.beginPath();
ctx.ellipse(MIDX, MIDY + rl + 18, (g.x2 + rl - g.x1 + rs) * 0.36, 7, 0, 0, Math.PI * 2);
ctx.fill();
paint(g);
if (g.touching) {
// 사이 막 — 옆에서 보이는 막이라 밝은 선
ctx.strokeStyle = rgba(filmColor(240, 1.4), 0.95);
ctx.lineWidth = 2.5;
ctx.beginPath();
if (g.flat) {
ctx.moveTo(g.xc, MIDY - g.hj);
ctx.lineTo(g.xc, MIDY + g.hj);
}
else {
const half = Math.asin(clamp(g.hj / g.rw, 0, 1));
ctx.arc(g.xw, MIDY, g.rw, -half, half);
}
ctx.stroke();
ctx.fillStyle = rgba(PAPER, 0.95);
for (const sgn of [-1, 1]) {
ctx.beginPath();
ctx.arc(g.xc, MIDY + sgn * g.hj, 3, 0, Math.PI * 2);
ctx.fill();
}
}
panelRow(0, 'Small bubble radius', String(rs) + ' px');
panelRow(1, 'Large bubble radius', String(rl) + ' px');
panelRow(2, 'Shared wall radius', !g.touching ? '-' : (g.flat ? 'Flat' : String(Math.round(g.rw)) + ' px'));
if (g.touching) {
const a = junctionAngles(g);
panelRow(3, 'Angles between three films', a.map(function (v) { return String(Math.round(v)); }).join(' · ') + '°');
}
else {
panelRow(3, 'Angles between three films', '-');
}
}
function after() {
if (still && phase !== 4) {
phase = 2;
phaseMs = 0;
}
report();
draw();
}
function setSmall(r) {
rs = clamp(Math.round(r), 30, R_LARGE);
after();
}
// Part and rejoin — 움직임 줄이기에서는 붙음과 떨어짐 두 장을 번갈아 보여 준다
function rejoin() {
if (still) {
phase = phase === 4 ? 2 : 4;
phaseMs = 0;
report();
draw();
return;
}
phase = 3;
phaseMs = 0;
after();
}
cv.addEventListener('pointerdown', function (ev) {
ev.preventDefault();
wake();
rejoin();
});
cv.addEventListener('keydown', function (ev) {
const k = ev.key;
if (k === 'ArrowUp' || k === 'ArrowRight') {
ev.preventDefault();
wake();
setSmall(rs + 4);
}
else if (k === 'ArrowDown' || k === 'ArrowLeft') {
ev.preventDefault();
wake();
setSmall(rs - 4);
}
else if (k === 'Enter' || k === ' ' || k === 'Spacebar') {
ev.preventDefault();
wake();
rejoin();
}
else if (k === 'Home') {
ev.preventDefault();
wake();
sizeIdx = 0;
rs = R_SMALL;
phase = 2;
phaseMs = 0;
after();
}
});
if (sizeBtn) {
sizeBtn.addEventListener('click', function () {
wake();
sizeIdx = (sizeIdx + 1) % SIZE_STEPS.length;
setSmall(SIZE_STEPS[sizeIdx]);
});
}
if (joinBtn) {
joinBtn.addEventListener('click', function () { wake(); rejoin(); });
}
// 시연은 다가가는 도중에서 시작한다 — 2초 안에 닿고 사이 막이 휘는 순간이 들어오게
if (demo) {
phaseMs = 200;
}
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
let looping = false;
function frame(ts) {
if (!looping) {
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) {
demoTick(clock);
}
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (looping) {
return;
}
looping = true;
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
looping = false;
window.cancelAnimationFrame(rafId);
}
// 움직임 줄이기 설정을 페이지를 연 채로 바꿔도 바로 따른다 — 켜면 루프를 멈추고 끝난 장면 한 장, 끄면 다시 돈다
function applyMotion() {
still = reduceQuery.matches;
if (still) {
stopLoop();
settle();
draw();
}
else {
startLoop();
}
}
reduceQuery.addEventListener('change', applyMotion);
applyMotion();
return function () {
stopLoop();
reduceQuery.removeEventListener('change', applyMotion);
window.removeEventListener('resize', onResize);
};
})();// SbTwinBubbleSharedWall - vanilla 판(06-sb-twin-bubble-shared-wall) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SbTwinBubbleSharedWall.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SbTwinBubbleSharedWall() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
const W = 480;
const H = 300;
const FIXED_DT = 16;
// 파스텔 팔레트 — 어두운 무대 위에 밝은 막을 얹어 명도 차로 방울이 읽힌다 (막의 무지갯빛은 이 표가 아니라 두께 계산에서 나온다)
const BG: RGB = [29, 26, 44];
const DEEP: RGB = [44, 39, 64];
const PAPER: RGB = [255, 248, 234];
const INK: RGB = [58, 48, 76];
const MINT: RGB = [126, 226, 192];
const SKY: RGB = [124, 196, 255];
const LILAC: RGB = [190, 164, 255];
const PINK: RGB = [255, 160, 190];
const PEACH: RGB = [255, 208, 168];
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
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) + ')';
}
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)]; }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !statusEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const status: HTMLElement = statusEl;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 만지는 순간 시연을 멈추고 상태 줄을 읽어 주기 시작한다
let demo = root.classList.contains('is-demo');
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
status.setAttribute('aria-live', 'polite');
}
function say(text: string): void {
if (status.textContent !== text) { status.textContent = text; }
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const 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 onResize(): void {
fitCanvas();
draw();
}
fitCanvas();
window.addEventListener('resize', onResize);
// 화면 좌표(CSS px) → 장면 좌표(480x300). 리사이즈 뒤에도 늘 현재 크기로 다시 잰다
function toScene(ev: PointerEvent): [number, number] {
const r = cv.getBoundingClientRect();
return [((ev.clientX - r.left) / r.width) * W, ((ev.clientY - r.top) / r.height) * H];
}
// 시드 고정 난수 — 같은 시드면 같은 장면이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 왼쪽 칸의 수치 — 이름표 한 줄 + 값 한 줄
function panelRow(row: number, label: string, value: string): void {
const y = 112 + row * 34;
ctx.textAlign = 'left';
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = rgba(PAPER, 0.62);
ctx.font = '500 11px sans-serif';
ctx.fillText(label, 16, y);
ctx.fillStyle = rgba(PAPER, 0.96);
ctx.font = '700 15px sans-serif';
ctx.fillText(value, 16, y + 18);
}
function clearStage(): void {
ctx.fillStyle = rgba(BG, 1);
ctx.fillRect(0, 0, W, H);
}
// 2D 값 노이즈 — 정수 격자 꼭짓점마다 시드 난수 값(−1~1)을 두고 그 사이를 부드러운 곡선으로 잇는다
const NOISE_SEED = 465;
const noisePerm = new Uint8Array(512);
const noiseVal = new Float32Array(256);
{
const nr = makeRng(NOISE_SEED);
for (let i = 0; i < 256; i += 1) {
noisePerm[i] = i;
noiseVal[i] = nr() * 2 - 1;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(nr() * (i + 1));
const t = noisePerm[i];
noisePerm[i] = noisePerm[j];
noisePerm[j] = t;
}
for (let i = 0; i < 256; i += 1) { noisePerm[i + 256] = noisePerm[i]; }
}
function noiseHash(ix: number, iy: number): number {
return noiseVal[noisePerm[noisePerm[ix & 255] + (iy & 255)]];
}
function noise2(x: number, y: number): number {
const xi = Math.floor(x);
const yi = Math.floor(y);
const fx = x - xi;
const fy = y - yi;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const a = noiseHash(xi, yi);
const b = noiseHash(xi + 1, yi);
const c = noiseHash(xi, yi + 1);
const d = noiseHash(xi + 1, yi + 1);
return lerp(lerp(a, b, u), lerp(c, d, u), v);
}
// 막의 굴절률 — 비눗물 막을 물과 같은 값으로 둔다
const FILM_N = 1.33;
// 세 파장(nm) — 화면의 빨강·초록·파랑 채널을 이 파장 하나씩으로 대신한다
const WAVE_RGB_NM: RGB = [650, 530, 450];
// 표를 만들 두께 범위(nm) — 1nm 눈금마다 세 채널 값을 미리 구해 둔다
const LUT_D_MAX = 1600;
const filmLut = new Float32Array((LUT_D_MAX + 1) * 3);
// 막 앞면과 뒷면에서 튕긴 두 빛이 두께만큼 어긋나 파장마다 남는 몫 — sin²(2π·n·d/λ).
// 앞면에서 튕긴 빛만 위상이 뒤집히므로 두께 0 이면 세 채널이 모두 0(검은 막). 값은 0~1 로 맞춘 비율이지 실제 반사율(몇 %)이 아니다
function reflectRGB(dNm: number): RGB {
const out: RGB = [0, 0, 0];
for (let c = 0; c < 3; c += 1) {
const s = Math.sin(2 * Math.PI * FILM_N * dNm / WAVE_RGB_NM[c]);
out[c] = s * s;
}
return out;
}
for (let d = 0; d <= LUT_D_MAX; d += 1) {
const r = reflectRGB(d);
filmLut[d * 3] = r[0];
filmLut[d * 3 + 1] = r[1];
filmLut[d * 3 + 2] = r[2];
}
// 비스듬히 보면 빛이 막 안에서 꺾여 지나므로 어긋남이 cos(꺾인 각)배로 줄어든다 — cosView 는 시선과 법선 사이 각의 cos
function insideCos(cosView: number): number {
const s2 = (1 - cosView * cosView) / (FILM_N * FILM_N);
return Math.sqrt(1 - s2);
}
// 두께 d(nm) 에서 채널 c 의 몫 — 표를 1nm 사이에서 선형 보간해 읽는다
function filmAt(dNm: number, c: number): number {
const d = clamp(dNm, 0, LUT_D_MAX - 1);
const i = Math.floor(d);
const f = d - i;
return filmLut[i * 3 + c] * (1 - f) + filmLut[(i + 1) * 3 + c] * f;
}
// 캔버스 선·면에 쓸 막 색 — 어두운 무대 위에 세 채널 몫을 밝기로 얹는다
function filmColor(dNm: number, gain: number): RGB {
return [BG[0] + filmAt(dNm, 0) * 235 * gain, BG[1] + filmAt(dNm, 1) * 235 * gain, BG[2] + filmAt(dNm, 2) * 235 * gain];
}
// 화소 하나를 막 색으로 — under 는 방울 뒤 바탕색, nx·ny·z 는 구면 위 법선(z 는 시선 쪽 성분)
function shadeFilm(px: Uint8ClampedArray, o: number, dNm: number, nx: number, ny: number, z: number, under: RGB, alpha: number): void {
const dEff = dNm * insideCos(z);
// 가장자리일수록 시선이 막을 비스듬히 지나 색이 진하다 — 가운데는 뒤 바탕이 비쳐 보이게 낮춘다
const amp = 0.22 + 0.78 * Math.pow(1 - z, 0.6);
// 왼쪽 위에서 오는 빛 하나의 반짝임
const hl = Math.pow(Math.max(0, nx * -0.42 + ny * -0.5 + z * 0.76), 60);
for (let c = 0; c < 3; c += 1) {
const v = under[c] + filmAt(dEff, c) * 235 * amp + hl * 220;
px[o + c] = under[c] + (v - under[c]) * alpha;
}
px[o + 3] = 255;
}
// 화소 계산판 — 작은 캔버스 하나에 ImageData 를 붙여 두고 화소를 직접 채운다
type Pix = { cvs: HTMLCanvasElement; pc: CanvasRenderingContext2D; img: ImageData; px: Uint8ClampedArray; w: number; h: number };
function makePix(w: number, h: number): Pix | null {
const c = document.createElement('canvas');
c.width = w;
c.height = h;
const pc = c.getContext('2d');
if (!pc) { return null; }
const img = pc.createImageData(w, h);
return { cvs: c, pc: pc, img: img, px: img.data, w: w, h: h };
}
// putImageData 는 캔버스 변환(배율)을 무시한다 — 작은 캔버스에 올린 뒤 drawImage 로 장면 좌표에 그린다
function showPix(p: Pix, x: number, y: number): void {
p.pc.putImageData(p.img, 0, 0);
ctx.drawImage(p.cvs, x, y, p.w, p.h);
}
// 06 — 작은 방울과 큰 방울이 붙으면 반지름 역수의 차로 사이 막의 휘어짐을 정하고, 세 막이 120도로 만나는 거리에 자리 잡는다
// 작은 방울·큰 방울 반지름(px)
const R_SMALL = 52;
const R_LARGE = 84;
// 닿은 뒤 자리를 잡기까지(ms)
const JOIN_MS = 900;
// 떨어져 있을 때 두 막 사이 틈(px)과 다가가기·떨어지기 시간(ms)
const GAP = 20;
const APPROACH_MS = 520;
const PART_MS = 360;
// 시연에서 붙은 채 머무는 시간(ms)
const HOLD_MS = 1100;
// 크기 바꾸기 버튼이 차례로 고르는 작은 방울 반지름
const SIZE_STEPS = [52, 38, 66, 84];
const MIDX = 322;
const MIDY = 150;
const X0 = 140;
const BW = 340;
const Y0 = 64;
const BH = 172;
const TXT_APPROACH = '두 방울이 다가가는 중';
const TXT_JOIN = '닿은 자리에 사이 막이 생기는 중';
const TXT_JOINED = '세 막이 120도로 만남';
const TXT_PART = '떨어지는 중';
const TXT_APART = '떨어져 있음';
const sizeBtn = root.querySelector('[data-role="size"]') as HTMLButtonElement | null;
const joinBtn = root.querySelector('[data-role="join"]') as HTMLButtonElement | null;
const pix = makePix(BW, BH);
const under: RGB = [BG[0], BG[1], BG[2]];
let rs = R_SMALL;
const rl = R_LARGE;
let sizeIdx = 0;
// 0 다가감 · 1 붙는 중 · 2 붙음 · 3 떨어지는 중 · 4 떨어져 멈춤(움직임 줄이기)
let phase = 0;
let phaseMs = 0;
let tSec = 0;
type Twin = { x1: number; x2: number; s: number; touching: boolean; hj: number; xc: number; flat: boolean; rw: number; xw: number; p: number };
// 세 막이 120도로 만나는 중심 거리 — 두 반지름이 60도로 벌어진 삼각형의 세 번째 변
function joinedDistance(): number { return Math.sqrt(rs * rs + rl * rl - rs * rl); }
function geometry(): Twin {
const deq = joinedDistance();
const touch = rs + rl;
const apart = touch + GAP;
let s = deq;
let p = 1;
if (phase === 0) { s = lerp(apart, touch, smooth(phaseMs / APPROACH_MS)); p = 0; }
else if (phase === 1) { p = smooth(phaseMs / JOIN_MS); s = lerp(touch, deq, p); }
else if (phase === 3) { p = 1 - smooth(phaseMs / PART_MS); s = lerp(deq, apart, 1 - p); }
else if (phase === 4) { s = apart; p = 0; }
const x1 = MIDX + (rs - rl - s) / 2;
const g: Twin = { x1: x1, x2: x1 + s, s: s, touching: s < touch - 0.01, hj: 0, xc: 0, flat: true, rw: 0, xw: 0, p: p };
if (g.touching) {
// 두 원의 교점 — 작은 원 중심에서 공통 현까지 거리 a, 현의 반 길이 hj
const a = (s * s + rs * rs - rl * rl) / (2 * s);
g.hj = Math.sqrt(Math.max(rs * rs - a * a, 0));
g.xc = x1 + a;
// 사이 막의 휘어짐 = p × (1/작은 반지름 − 1/큰 반지름). 다 붙으면(p=1) 두 방울 압력 차와 맞는 값
const kappa = p * (1 / rs - 1 / rl);
if (kappa > 1e-6) {
g.flat = false;
g.rw = 1 / kappa;
// 사이 막 원의 중심은 작은 방울 쪽 — 그래서 막은 큰 방울 쪽으로 불룩하다
g.xw = g.xc - Math.sqrt(Math.max(g.rw * g.rw - g.hj * g.hj, 0));
}
}
return g;
}
// 위쪽 교점에서 세 막의 접선 사이 각(도) — 작은·큰 바깥 막, 큰 막·사이 막, 사이 막·작은 막
function tangentAway(cx: number, rx: number, ry: number, ox: number, orad: number): [number, number] {
const len = Math.hypot(rx - cx, ry - MIDY);
let tx = -(ry - MIDY) / len;
let ty = (rx - cx) / len;
const qx = rx + tx * 2 - ox;
const qy = ry + ty * 2 - MIDY;
if (qx * qx + qy * qy < orad * orad) { tx = -tx; ty = -ty; }
return [tx, ty];
}
function junctionAngles(g: Twin): [number, number, number] {
const jx = g.xc;
const jy = MIDY - g.hj;
const t1 = tangentAway(g.x1, jx, jy, g.x2, rl);
const t2 = tangentAway(g.x2, jx, jy, g.x1, rs);
let t3: [number, number] = [0, 1];
if (!g.flat) {
const len = Math.hypot(jx - g.xw, jy - MIDY);
t3 = [-(jy - MIDY) / len, (jx - g.xw) / len];
if (t3[1] < 0) { t3 = [-t3[0], -t3[1]]; }
}
const ang = function (a: [number, number], b: [number, number]): number {
return Math.acos(clamp(a[0] * b[0] + a[1] * b[1], -1, 1)) * 180 / Math.PI;
};
return [ang(t1, t2), ang(t2, t3), ang(t3, t1)];
}
function paint(g: Twin): void {
if (!pix) { return; }
const px = pix.px;
for (let j = 0; j < BH; j += 1) {
const y = Y0 + j + 0.5;
const dy = y - MIDY;
for (let i = 0; i < BW; i += 1) {
const x = X0 + i + 0.5;
const o = (j * BW + i) * 4;
const d1 = Math.hypot(x - g.x1, dy);
const d2 = Math.hypot(x - g.x2, dy);
const in1 = d1 < rs;
const in2 = d2 < rl;
if (!in1 && !in2) { px[o + 3] = 0; continue; }
let small = in1;
if (g.touching) {
small = g.flat ? x < g.xc : Math.hypot(x - g.xw, dy) < g.rw;
}
const cx = small ? g.x1 : g.x2;
const rr = small ? rs : rl;
const nx = (x - cx) / rr;
const ny = dy / rr;
const z = Math.sqrt(Math.max(1 - nx * nx - ny * ny, 0));
const base = small ? 300 : 520;
const d = base * (1 + 0.3 * ny) + (small ? 70 : 90) * noise2(nx * 1.6 + tSec * 0.2 + (small ? 0 : 5), ny * 1.6);
shadeFilm(px, o, Math.max(d, 0), nx, ny, z, under, 1);
// 바깥 테두리 한 화소는 반쯤 비친다
px[o + 3] = 255 * clamp(Math.max(in1 ? rs - d1 : 0, in2 ? rl - d2 : 0), 0, 1);
}
}
showPix(pix, X0, Y0);
}
function report(): void {
if (phase === 0) { say(TXT_APPROACH); }
else if (phase === 1) { say(TXT_JOIN); }
else if (phase === 2) { say(TXT_JOINED); }
else if (phase === 3) { say(TXT_PART); }
else { say(TXT_APART); }
}
function step(): void {
tSec += FIXED_DT / 1000;
phaseMs += FIXED_DT;
if (phase === 0 && phaseMs >= APPROACH_MS) { phase = 1; phaseMs = 0; }
else if (phase === 1 && phaseMs >= JOIN_MS) { phase = 2; phaseMs = 0; }
else if (phase === 3 && phaseMs >= PART_MS) {
if (demo) { sizeIdx = (sizeIdx + 1) % SIZE_STEPS.length; rs = SIZE_STEPS[sizeIdx]; }
phase = 0;
phaseMs = 0;
}
report();
}
function settle(): void {
if (phase !== 4) { phase = 2; }
phaseMs = 0;
report();
}
function demoTick(t: number): void {
if (t >= 0 && phase === 2 && phaseMs >= HOLD_MS) { phase = 3; phaseMs = 0; }
}
function draw(): void {
clearStage();
const g = geometry();
ctx.fillStyle = rgba(DEEP, 0.9);
ctx.beginPath();
ctx.ellipse(MIDX, MIDY + rl + 18, (g.x2 + rl - g.x1 + rs) * 0.36, 7, 0, 0, Math.PI * 2);
ctx.fill();
paint(g);
if (g.touching) {
// 사이 막 — 옆에서 보이는 막이라 밝은 선
ctx.strokeStyle = rgba(filmColor(240, 1.4), 0.95);
ctx.lineWidth = 2.5;
ctx.beginPath();
if (g.flat) {
ctx.moveTo(g.xc, MIDY - g.hj);
ctx.lineTo(g.xc, MIDY + g.hj);
} else {
const half = Math.asin(clamp(g.hj / g.rw, 0, 1));
ctx.arc(g.xw, MIDY, g.rw, -half, half);
}
ctx.stroke();
ctx.fillStyle = rgba(PAPER, 0.95);
for (const sgn of [-1, 1]) {
ctx.beginPath();
ctx.arc(g.xc, MIDY + sgn * g.hj, 3, 0, Math.PI * 2);
ctx.fill();
}
}
panelRow(0, '작은 방울 반지름', String(rs) + ' px');
panelRow(1, '큰 방울 반지름', String(rl) + ' px');
panelRow(2, '사이 막 반지름', !g.touching ? '-' : (g.flat ? '평평' : String(Math.round(g.rw)) + ' px'));
if (g.touching) {
const a = junctionAngles(g);
panelRow(3, '세 막 사이 각', a.map(function (v: number): string { return String(Math.round(v)); }).join(' · ') + '°');
} else {
panelRow(3, '세 막 사이 각', '-');
}
}
function after(): void {
if (still && phase !== 4) { phase = 2; phaseMs = 0; }
report();
draw();
}
function setSmall(r: number): void {
rs = clamp(Math.round(r), 30, R_LARGE);
after();
}
// 떼었다 붙이기 — 움직임 줄이기에서는 붙음과 떨어짐 두 장을 번갈아 보여 준다
function rejoin(): void {
if (still) {
phase = phase === 4 ? 2 : 4;
phaseMs = 0;
report();
draw();
return;
}
phase = 3;
phaseMs = 0;
after();
}
cv.addEventListener('pointerdown', function (ev: PointerEvent): void {
ev.preventDefault();
wake();
rejoin();
});
cv.addEventListener('keydown', function (ev: KeyboardEvent): void {
const k = ev.key;
if (k === 'ArrowUp' || k === 'ArrowRight') { ev.preventDefault(); wake(); setSmall(rs + 4); }
else if (k === 'ArrowDown' || k === 'ArrowLeft') { ev.preventDefault(); wake(); setSmall(rs - 4); }
else if (k === 'Enter' || k === ' ' || k === 'Spacebar') { ev.preventDefault(); wake(); rejoin(); }
else if (k === 'Home') { ev.preventDefault(); wake(); sizeIdx = 0; rs = R_SMALL; phase = 2; phaseMs = 0; after(); }
});
if (sizeBtn) {
sizeBtn.addEventListener('click', function (): void {
wake();
sizeIdx = (sizeIdx + 1) % SIZE_STEPS.length;
setSmall(SIZE_STEPS[sizeIdx]);
});
}
if (joinBtn) { joinBtn.addEventListener('click', function (): void { wake(); rejoin(); }); }
// 시연은 다가가는 도중에서 시작한다 — 2초 안에 닿고 사이 막이 휘는 순간이 들어오게
if (demo) { phaseMs = 200; }
report();
let rafId = 0;
let lastTs = -1;
let acc = 0;
let clock = 0;
let looping = false;
function frame(ts: number): void {
if (!looping) { return; }
if (lastTs < 0) { lastTs = ts; }
// 탭이 숨었다 돌아와도 한 번에 100ms 까지만 따라잡는다 — 순간이동 대신 이어서 움직인다
acc += Math.min(ts - lastTs, 100);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < 6) {
clock += FIXED_DT;
if (demo) { demoTick(clock); }
step();
acc -= FIXED_DT;
n += 1;
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (looping) { return; }
looping = true;
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
looping = false;
window.cancelAnimationFrame(rafId);
}
// 움직임 줄이기 설정을 페이지를 연 채로 바꿔도 바로 따른다 — 켜면 루프를 멈추고 끝난 장면 한 장, 끄면 다시 돈다
function applyMotion(): void {
still = reduceQuery.matches;
if (still) {
stopLoop();
settle();
draw();
} else {
startLoop();
}
}
reduceQuery.addEventListener('change', applyMotion);
applyMotion();
return function (): void {
stopLoop();
reduceQuery.removeEventListener('change', applyMotion);
window.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={cx("hs") + " is-demo"} ref={rootRef}>
<canvas className={cx("hs__canvas")} data-role="canvas" width={480} height={300} tabIndex={0} aria-label="작은 방울과 큰 방울이 붙어 사이에 큰 방울 쪽으로 불룩한 막이 생기고 세 막이 만나는 각을 재는 쌍둥이 방울. 누르면 떼었다 다시 붙인다"></canvas>
<p className={cx("hs__status")} data-role="status" aria-live="off">두 방울이 다가가는 중</p>
<div className={cx("hs__tools")}>
<button className={cx("hs__btn")} data-role="size" type="button">크기 바꾸기</button>
<button className={cx("hs__btn")} data-role="join" type="button">떼었다 붙이기</button>
</div>
</div>
);
}06 Twin Bubbles That Join and Share One Curved Wall Between Them — Measured area 23.855% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Bubble Animation CSS: 9 Soap-Film Scenes That Pop
The post this demo lives in
Bubble Animation CSS: 9 Soap-Film Scenes That PopA soap bubble's rainbow is not painted on — 01 computes color from film thickness, 04 pops from a poked rim that peels b See all demos in this post
If you like this effect - similar demos
Interactions09 A Popover Ring That Pushes Apart and Settles Around One AnchorCSS Popover: 9 Ways to Anchor, Flip and Spring
Interactions09 Email and embed share boxSocial Share Buttons: 9 Copy-Paste UI Patterns
Interactions03 Fallback selection — a card that leaves a manual path when the fast one fails9 Copy Button CSS UI Patterns (Copy to Clipboard)