07波の渦 — 離岸流の渦パッチ
波の場に局所回転(強さが半径で減衰)を重ね、特定地点の波が束になり沖へ流れ出す離岸流を表現し、渦の中心が時間とともにゆっくり移動します。
使いどころ海洋安全・教育コンテンツの可視化
rotationfalloffImageData

<main class="stage">
<div class="rp" id="rp">
<p class="rp__hint">
<svg class="rp__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>波の場に局所回転を重ねて渦を作ります — マウスを乗せると渦の中心がそこへ移動します</span>
</p>
<div class="rp__field" id="field" role="application" aria-label="波の渦お試しパネル" tabindex="0">
<canvas class="rp__cv" id="cv"></canvas>
<b class="rp__badge" aria-hidden="true"><span class="rp__badgeNum" id="swirlVal">回転 1.0</span><span class="rp__badgeUnit" id="windVal">風 1.0</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파도 소용돌이 — 파도장 위에 국소 회전(와류)을 얹는 이안류 패치.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 를 두지 않는다(077 방식).
// 색은 전부 토큰 또는 mix() 로컬 변수(BD26) — 헥스 리터럴 금지.
// 캔버스가 쓰는 색은 커스텀 프로퍼티로 내려보낸다 — 값은 이 파일 한 곳에만 둔다.
$rp-crest: mix($subject-mint, $stage-paper, 72%);
$rp-crest2: mix($subject-sky, $stage-paper, 66%);
$rp-trough: mix($subject-blue-deep, $stage-paper, 52%);
$rp-trough2: mix($subject-blue, $stage-paper, 58%);
$rp-foam: mix($subject-cream, $stage-paper, 88%); // 와류 중심 거품 — 밝은 크림
$duration: $dur-loop;
$easing: $ease-spring;
$color: $rp-foam;
@include stage($stage-paper);
.rp {
--crest: #{$rp-crest};
--crest-2: #{$rp-crest2};
--trough: #{$rp-trough};
--trough-2: #{$rp-trough2};
--foam: #{$rp-foam};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rp__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.rp__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-blue; }
.rp__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $rp-trough2;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: crosshair; touch-action: none;
}
.rp__field:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.rp__cv { display: block; width: 100%; height: 100%; }
// 와류 세기·바람 세기를 화면에도 적어 둔다 — frame() 이 갱신한다
.rp__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.rp__badgeNum { font-size: 13px; color: $stage-yellow; }
.rp__badgeUnit { font-weight: 700; }
@media (max-width: 340px) {
.rp { gap: $sp-1; }
.rp__field { height: 124px; }
.rp__badge { font-size: 10px; padding: 0 $sp-1; }
.rp__badgeNum { font-size: 11px; }
.rp__hint { font-size: 10px; }
}
// reduce — 루프가 아예 안 돈다. 소용돌이가 살아 있는 정적 한 장이 남으므로
// 끌 수 있다는 신호(커서)만 거둔다.
@media (prefers-reduced-motion: reduce) {
.rp.is-still .rp__field { cursor: default !important; }
.rp.is-still .rp__badge { opacity: .92 !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);
}
.rp {
--crest: rgb(49.4588235294%, 87.8588235294%, 74.8705882353%);
--crest-2: rgb(62.4705882353%, 85.7647058824%, 100%);
--trough: rgb(50.0392156863%, 64.1098039216%, 95.3098039216%);
--trough-2: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
--foam: rgb(100%, 97.2392156863%, 91.3725490196%);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rp__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.rp__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #2f6df6;
}
.rp__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: rgb(52.6901960784%, 66.7921568627%, 97.9529411765%);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: crosshair;
touch-action: none;
}
.rp__field:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rp__cv {
display: block;
width: 100%;
height: 100%;
}
.rp__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.rp__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.rp__badgeUnit {
font-weight: 700;
}
@media (max-width: 340px) {
.rp {
gap: 4px;
}
.rp__field {
height: 124px;
}
.rp__badge {
font-size: 10px;
padding: 0 4px;
}
.rp__badgeNum {
font-size: 11px;
}
.rp__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-still .rp__field {
cursor: default !important;
}
.rp.is-still .rp__badge {
opacity: 0.92 !important;
}
}// 파도 소용돌이(이안류 패치) — 01 과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장 위에
// 국소 와류를 한 겹 얹는다. 回転 유도체 접근: 각 셀의 좌표를 와류 중심 기준 극좌표로 바꿔
// 위상에 -각도(回転)와 반경 비례 감쇠(ramp)를 더한다. 위상이 비틀리면 정현파의 마루·골이
// 와류 주변에서 나선처럼 감기며 "파도가 뭉쳐 바깥으로 빠져나가는" 이안류의 인상을 준다.
// 와류 중심은 자동 모드에서 리사주로 천천히 옮겨 다니고, 포인터가 있으면 그 위치를 따라간다.
// 와류 주변 밝기(泡 농도)는 반경 가우시안으로 부풀려 중심이 물빛보다 밝게 뜨게 한다.
var root = document.getElementById('rp');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var swirlVal = document.getElementById('swirlVal');
var windVal = document.getElementById('windVal');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var CREST = [v('--crest'), v('--crest-2')]; // 마루 색 램프 — 민트 계열
var TROUGH = [v('--trough'), v('--trough-2')]; // 골 색 램프 — 파랑 계열
var FOAM = v('--foam'); // 와류 중심 泡 — 밝은 크림
var COLS = 110; // 파도장 가로 칸 수(01 과 동일)
var STEP = 3; // 격자 한 칸이 렌더 버퍼에서 차지하는 픽셀
var NW = 6; // 겹칠 정현파 개수(공통 물리)
var SWIRL = 2.6; // 와류 세기 — 위상을 이만큼 비튼다(라디안 스케일)
var VORTEX_R = 30;// 와류 반경(격자 칸 수) — 이 안에서 회전이 감쇠하며 살아 있다
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
var u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
var rng = mulberry32(7);
var WAVES = [];
(function () {
var lam = 34;
for (var i = 0; i < NW; i++) {
var ang = rng() * Math.PI * 2;
WAVES.push({
lam: lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang), dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2
});
lam *= 0.55;
}
})();
for (var w = 0; w < NW; w++) {
WAVES[w].om = WAVES[w].spd * 3.6 * Math.sqrt(1 / WAVES[w].lam) * 6;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, ROWS = 0;
var hgt = null; // 파도장 높이(위상 비틀림 반영)
var foam = null; // 와류 泡 농도(0..1)
var buf = null, bctx = null, out = null;
var RW = 0, RH = 0;
var raf = 0, auto = true, nowSec = 0;
var wind = 1.0, windT = 1.0; // 風 세기(실효 진폭)
var windAng = 0.5, windAngT = 0.5; // 風 방향
var vx = 0.5, vy = 0.42; // 와류 중심(판 비율 0..1) 현재
var vxT = 0.5, vyT = 0.42; // 목표(포인터 또는 자동 리사주)
var swirl = 1.0, swirlT = 1.0; // 와류 세기 배율
var lastPX = -1, lastPY = -1, lastPT = 0;
function hex2rgb(s) {
var pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(pm[1] * 2.55), Math.round(pm[2] * 2.55), Math.round(pm[3] * 2.55)];
s = s.replace('#', '');
if (s.length === 3) s = s[0] + s[0] + s[1] + s[1] + s[2] + s[2];
var n = parseInt(s, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
var C0 = hex2rgb(CREST[0]), C1 = hex2rgb(CREST[1]);
var T0 = hex2rgb(TROUGH[0]), T1 = hex2rgb(TROUGH[1]);
var FC = hex2rgb(FOAM);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ROWS = Math.max(40, Math.round(COLS * H / W));
var n = COLS * ROWS;
hgt = new Float32Array(n);
foam = new Float32Array(n);
RW = COLS * STEP; RH = ROWS * STEP;
buf = document.createElement('canvas');
buf.width = RW; buf.height = RH;
bctx = buf.getContext('2d');
out = bctx.createImageData(RW, RH);
}
// 파도장 + 와류 — height(x,y,t) = Σ amp·sin(위상 + 비틀림(각도·반경))
// 비틀림 twist = SWIRL·세기·(1 - r/R)·θ … θ 는 중심 기준 방위각, r 은 반경.
// 반경이 R 을 넘으면 twist=0 — 와류가 국소 패치로만 살게.
function fieldcalc(t, wstr, wang, cx, cy, sw) {
var i = 0;
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++, i++) {
var dxv = x - cx, dyv = y - cy;
var r = Math.sqrt(dxv * dxv + dyv * dyv);
var ang = Math.atan2(dyv, dxv);
var fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
fall *= fall; // 감쇠를 가속 — 중심만 또렷하게
var twist = SWIRL * sw * fall * ang;
var h = 0;
for (var w2 = 0; w2 < NW; w2++) {
var WV = WAVES[w2];
var da = wang * 0.5;
var dx = WV.dirX * Math.cos(da) - WV.dirY * Math.sin(da);
var dy = WV.dirX * Math.sin(da) + WV.dirY * Math.cos(da);
var ph = (x * dx + y * dy) * WV.freq * 0.62 + t * WV.om + WV.ph;
h += WV.amp * wstr * Math.sin(ph + twist);
}
hgt[i] = h;
// 泡 농도 — 와류 중심 가우시안 + 파도 마루 겹침. 진폭이 뭉치는 자리가 밝다
var foamv = fall * 0.85 + Math.max(0, h) * 0.5;
if (foamv > 1) foamv = 1;
foam[i] = foamv;
}
}
}
// 렌더 — 높이→색 램프(골 파랑 → 마루 민트) 뒤 泡 농도만큼 밝은 크림으로 당긴다.
// 와류가 옮겨 다니면 밝은 패치가 파도를 헤치고 다니는 게 눈에 띄어 누적면적이 크게 잡힌다.
function render() {
var o = out.data;
for (var y = 0; y < ROWS; y++) {
var row = y * COLS;
for (var x = 0; x < COLS; x++) {
var i = row + x;
var h = hgt[i];
var u = h * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
var r, g, b;
if (u > 0.5) {
var k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
var k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
var f = foam[i];
if (f > 0.02) {
r = r + (FC[0] - r) * f * 0.8;
g = g + (FC[1] - g) * f * 0.8;
b = b + (FC[2] - b) * f * 0.8;
}
for (var sy = 0; sy < STEP; sy++) {
var base = ((y * STEP + sy) * RW + x * STEP) * 4;
for (var sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
bctx.putImageData(out, 0, 0);
ctx.drawImage(buf, 0, 0, W, H);
}
function frame(now) {
nowSec = now / 1000;
if (auto) {
var th = nowSec * 0.31;
windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
// 와류 중심이 판 안을 천천히 배회 — 두 주파수 리사주로 예측 불가능하게
vxT = 0.5 + Math.sin(th * 0.37 + 1.2) * 0.3;
vyT = 0.44 + Math.sin(th * 0.53 + 2.1) * 0.26;
swirlT = 1.0 + Math.sin(th * 0.7) * 0.35;
}
wind += (windT - wind) * 0.05;
windAng += (windAngT - windAng) * 0.05;
vx += (vxT - vx) * 0.045;
vy += (vyT - vy) * 0.045;
swirl += (swirlT - swirl) * 0.04;
fieldcalc(nowSec, wind, windAng, vx * COLS, vy * ROWS, swirl);
render();
swirlVal.textContent = '回転 ' + swirl.toFixed(1);
windVal.textContent = '風 ' + wind.toFixed(2);
raf = requestAnimationFrame(frame);
}
// 정지판 — 루프 없이 시드가 정한 한 시각의 와류를 계산해 소용돌이가 살아 있는 정적 한 장
function still() {
fieldcalc(7.3, 1.35, 0.5, 0.46 * COLS, 0.42 * ROWS, 1.2);
render();
}
function localXY(e) {
var r = field.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
field.addEventListener('pointermove', function (e) {
var p = localXY(e);
var t = performance.now();
// 포인터 = 와류 중심 목표. 이동 벡터는 바람으로도 실측(01 방식)
vxT = Math.max(0.04, Math.min(0.96, p[0] / W));
vyT = Math.max(0.04, Math.min(0.96, p[1] / H));
if (lastPX >= 0 && t > lastPT) {
var dxp = p[0] - lastPX, dyp = p[1] - lastPY;
var sp2 = Math.sqrt(dxp * dxp + dyp * dyp);
if (sp2 > 0.5) {
windT = Math.max(0.35, Math.min(2.1, 0.9 + sp2 * 0.02));
windAngT = Math.atan2(dyp, dxp) * 0.16;
}
}
lastPX = p[0]; lastPY = p[1]; lastPT = t;
auto = false;
});
field.addEventListener('pointerleave', function () {
auto = true;
lastPX = -1;
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[e.key];
if (!d) return;
e.preventDefault();
auto = false;
vxT = Math.max(0.05, Math.min(0.95, vxT + d[0] * 0.06));
vyT = Math.max(0.05, Math.min(0.95, vyT + d[1] * 0.06));
swirlT = Math.max(0.3, Math.min(1.9, swirlT + (d[1] < 0 ? 0.12 : -0.12)));
if (reduce) { fieldcalc(nowSec || 3.1, windT, windAngT, vxT * COLS, vyT * ROWS, swirlT); render(); }
});
window.addEventListener('resize', function () { fit(); if (reduce) still(); });
fit();
if (reduce) {
root.classList.add('is-still');
still();
} else {
raf = requestAnimationFrame(frame);
}// RipCurrent — vanilla 판(run/313/demo/07-ocean-rip-current/index.html) 의 React 포트.
// 01(GerstnerSum)과 같은 뿌리(정현파 6개 mulberry32(7))의 파도장에 국소 와류(회전 유도체)를
// 한 겹 얹는다 — 셀 좌표를 와류 중심 기준 극좌표로 바꿔 위상에 각도·반경 감쇠를 더한다.
// 와류 중심은 자동 리사주로 배회하거나 포인터를 따라간다. 움직임은 rAF 루프가 전부.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RipCurrent.module.scss";
export type RipCurrentProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type PointerMotionEvent = { clientX: number; clientY: number };
const COLS = 110;
const STEP = 3;
const NW = 6;
const SWIRL = 2.6;
const VORTEX_R = 30;
function mulberry32(a: number): () => number {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
const t = Math.imul(a ^ (a >>> 15), 1 | a);
const u = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((u ^ (u >>> 14)) >>> 0) / 4294967296;
};
}
type Wave = {
lam: number; freq: number; dirX: number; dirY: number;
amp: number; spd: number; ph: number; om: number;
};
function buildWaves(): Wave[] {
const rng = mulberry32(7);
const out: Wave[] = [];
let lam = 34;
for (let i = 0; i < NW; i++) {
const ang = rng() * Math.PI * 2;
const wv: Wave = {
lam,
freq: (Math.PI * 2) / lam,
dirX: Math.cos(ang),
dirY: Math.sin(ang),
amp: 0.58 / (i * 0.62 + 1),
spd: 0.55 + rng() * 0.25,
ph: rng() * Math.PI * 2,
om: 0,
};
wv.om = wv.spd * 3.6 * Math.sqrt(1 / wv.lam) * 6;
out.push(wv);
lam *= 0.55;
}
return out;
}
function hex2rgb(s: string): [number, number, number] {
const pm = /^\s*rgb\(\s*([\d.]+)%\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)\s*$/.exec(s);
if (pm) return [Math.round(+pm[1] * 2.55), Math.round(+pm[2] * 2.55), Math.round(+pm[3] * 2.55)];
let t = s.replace("#", "");
if (t.length === 3) t = t[0] + t[0] + t[1] + t[1] + t[2] + t[2];
const n = parseInt(t, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export default function RipCurrent({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4ead2",
}: RipCurrentProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const swirlValRef = useRef<HTMLSpanElement | null>(null);
const windValRef = useRef<HTMLSpanElement | null>(null);
const wavesRef = useRef<Wave[] | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const rafRef = useRef(0) as Box<number>;
const stillRef = useRef(false) as Box<boolean>;
type St = {
rows: number; rw: number; rh: number;
hgt: Float32Array | null;
foam: Float32Array | null;
out: ImageData | null;
bctx: CanvasRenderingContext2D | null;
buf: HTMLCanvasElement | null;
auto: boolean;
wind: number; windT: number;
windAng: number; windAngT: number;
vx: number; vy: number; vxT: number; vyT: number;
swirl: number; swirlT: number;
lastPX: number; lastPY: number; lastPT: number;
c0: [number, number, number] | null;
c1: [number, number, number] | null;
t0: [number, number, number] | null;
t1: [number, number, number] | null;
fc: [number, number, number] | null;
};
const stateRef = useRef({
rows: 0, rw: 0, rh: 0,
hgt: null as Float32Array | null,
foam: null as Float32Array | null,
out: null as ImageData | null,
bctx: null as CanvasRenderingContext2D | null,
buf: null as HTMLCanvasElement | null,
auto: true,
wind: 1.0, windT: 1.0,
windAng: 0.5, windAngT: 0.5,
vx: 0.5, vy: 0.42, vxT: 0.5, vyT: 0.42,
swirl: 1.0, swirlT: 1.0,
lastPX: -1, lastPY: -1, lastPT: 0,
c0: null as [number, number, number] | null,
c1: null as [number, number, number] | null,
t0: null as [number, number, number] | null,
t1: null as [number, number, number] | null,
fc: null as [number, number, number] | null,
} as St) as Box<St>;
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function fit(): void {
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
const ctx = ctxRef.current;
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const st = stateRef.current;
st.rows = Math.max(40, Math.round(COLS * r.height / r.width));
const n = COLS * st.rows;
st.hgt = new Float32Array(n);
st.foam = new Float32Array(n);
st.rw = COLS * STEP;
st.rh = st.rows * STEP;
const buf = document.createElement("canvas");
buf.width = st.rw; buf.height = st.rh;
st.buf = buf;
st.bctx = buf.getContext("2d");
if (st.bctx) st.out = st.bctx.createImageData(st.rw, st.rh);
}
function fieldcalc(t: number, wstr: number, wang: number, cx: number, cy: number, sw: number): void {
const st = stateRef.current;
const WV = wavesRef.current;
if (!st.hgt || !st.foam || !WV) return;
let i = 0;
for (let y = 0; y < st.rows; y++) {
for (let x = 0; x < COLS; x++, i++) {
const dxv = x - cx, dyv = y - cy;
const r = Math.sqrt(dxv * dxv + dyv * dyv);
const ang = Math.atan2(dyv, dxv);
let fall = r < VORTEX_R ? 1 - r / VORTEX_R : 0;
fall *= fall;
const twist = SWIRL * sw * fall * ang;
let h = 0;
for (let k = 0; k < NW; k++) {
const wv = WV[k];
const da = wang * 0.5;
const dx = wv.dirX * Math.cos(da) - wv.dirY * Math.sin(da);
const dy = wv.dirX * Math.sin(da) + wv.dirY * Math.cos(da);
const ph = (x * dx + y * dy) * wv.freq * 0.62 + t * wv.om + wv.ph;
h += wv.amp * wstr * Math.sin(ph + twist);
}
st.hgt[i] = h;
let foamv = fall * 0.85 + Math.max(0, h) * 0.5;
if (foamv > 1) foamv = 1;
st.foam[i] = foamv;
}
}
}
function render(): void {
const st = stateRef.current;
const ctx = ctxRef.current;
if (!st.out || !st.bctx || !st.buf || !ctx || !st.hgt || !st.foam) return;
if (!st.c0 || !st.c1 || !st.t0 || !st.t1 || !st.fc) return;
const o = st.out.data;
const C0 = st.c0, C1 = st.c1, T0 = st.t0, T1 = st.t1, FC = st.fc;
for (let y = 0; y < st.rows; y++) {
const row = y * COLS;
for (let x = 0; x < COLS; x++) {
const i = row + x;
let u = st.hgt[i] * 1.24 + 0.5;
if (u < 0) u = 0; else if (u > 1) u = 1;
let r: number, g: number, b: number;
if (u > 0.5) {
const k = (u - 0.5) * 2;
r = C1[0] + (C0[0] - C1[0]) * k;
g = C1[1] + (C0[1] - C1[1]) * k;
b = C1[2] + (C0[2] - C1[2]) * k;
} else {
const k2 = u * 2;
r = T0[0] + (T1[0] - T0[0]) * k2;
g = T0[1] + (T1[1] - T0[1]) * k2;
b = T0[2] + (T1[2] - T0[2]) * k2;
}
const f = st.foam[i];
if (f > 0.02) {
r = r + (FC[0] - r) * f * 0.8;
g = g + (FC[1] - g) * f * 0.8;
b = b + (FC[2] - b) * f * 0.8;
}
for (let sy = 0; sy < STEP; sy++) {
let base = ((y * STEP + sy) * st.rw + x * STEP) * 4;
for (let sx = 0; sx < STEP; sx++, base += 4) {
o[base] = r;
o[base + 1] = g;
o[base + 2] = b;
o[base + 3] = 255;
}
}
}
}
st.bctx.putImageData(st.out, 0, 0);
ctx.drawImage(st.buf, 0, 0, sizeRef.current.w, sizeRef.current.h);
}
function frame(now: number): void {
const st = stateRef.current;
const sec = now / 1000;
if (st.auto) {
const th = sec * 0.31;
st.windT = 1.05 + Math.sin(th * 0.9 + 0.4) * 0.55 + Math.sin(th * 2.3) * 0.12;
st.windAngT = 0.5 + Math.sin(th * 0.42) * 0.85;
st.vxT = 0.5 + Math.sin(th * 0.37 + 1.2) * 0.3;
st.vyT = 0.44 + Math.sin(th * 0.53 + 2.1) * 0.26;
st.swirlT = 1.0 + Math.sin(th * 0.7) * 0.35;
}
st.wind += (st.windT - st.wind) * 0.05;
st.windAng += (st.windAngT - st.windAng) * 0.05;
st.vx += (st.vxT - st.vx) * 0.045;
st.vy += (st.vyT - st.vy) * 0.045;
st.swirl += (st.swirlT - st.swirl) * 0.04;
fieldcalc(sec, st.wind, st.windAng, st.vx * COLS, st.vy * st.rows, st.swirl);
render();
if (swirlValRef.current) swirlValRef.current.textContent = "회전 " + st.swirl.toFixed(1);
if (windValRef.current) windValRef.current.textContent = "바람 " + st.wind.toFixed(2);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: PointerMotionEvent): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const t = performance.now();
const st = stateRef.current;
st.vxT = Math.max(0.04, Math.min(0.96, px / sizeRef.current.w));
st.vyT = Math.max(0.04, Math.min(0.96, py / sizeRef.current.h));
if (st.lastPX >= 0 && t > st.lastPT) {
const vx = px - st.lastPX, vy = py - st.lastPY;
const sp = Math.sqrt(vx * vx + vy * vy);
if (sp > 0.5) {
st.windT = Math.max(0.35, Math.min(2.1, 0.9 + sp * 0.02));
st.windAngT = Math.atan2(vy, vx) * 0.16;
}
}
st.lastPX = px; st.lastPY = py; st.lastPT = t;
st.auto = false;
}
function onPointerLeave(): void {
stateRef.current.auto = true;
stateRef.current.lastPX = -1;
}
function onResize(): void {
fit();
if (reduced()) {
const st = stateRef.current;
fieldcalc(7.3, 1.35, 0.5, 0.46 * COLS, 0.42 * st.rows, 1.2);
render();
}
}
useEffect(() => {
const cv = cvRef.current;
const root = rootRef.current;
if (!cv || !root) return;
ctxRef.current = cv.getContext("2d");
if (!ctxRef.current) return;
wavesRef.current = buildWaves();
const cs = getComputedStyle(root);
const tok = (name: string) => cs.getPropertyValue(name).trim();
const st = stateRef.current;
st.c0 = hex2rgb(tok("--crest"));
st.c1 = hex2rgb(tok("--crest-2"));
st.t0 = hex2rgb(tok("--trough"));
st.t1 = hex2rgb(tok("--trough-2"));
st.fc = hex2rgb(tok("--foam"));
fit();
window.addEventListener("resize", onResize);
if (reduced()) {
stillRef.current = true;
fieldcalc(7.3, 1.35, 0.5, 0.46 * COLS, 0.42 * st.rows, 1.2);
render();
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
const cls = [styles.root, stillRef.current ? styles.isStill : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M16 120c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".25"/><path d="M16 168c24-32 48-32 72 0s48 32 72 0 48-32 72 0" opacity=".15"/><path d="M228.92 49.09a112 112 0 1 0 14 84.91a8 8 0 0 0-15.5-4a96 96 0 1 1-12-72.91A8 8 0 0 1 216 71.63V40a8 8 0 0 1-8-8h-31.63a8 8 0 0 1-5.65-13.09ZM64 104a12 12 0 1 1 12 12a12 12 0 0 1-12-12m40 64a12 12 0 1 1 12 12a12 12 0 0 1-12-12m72-16a16 16 0 1 1 16 16a16 16 0 0 1-16-16" opacity=".9"/></g></svg>
<span>파도장에 국소 회전을 한 겹 얹어 소용돌이를 만듭니다 — 마우스를 대면 회전 중심이 그리로 갑니다</span>
</p>
<div className={styles.field} ref={fieldRef} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave}>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={swirlValRef}>회전 1.0</span><span className={styles.badgeUnit} ref={windValRef}>바람 1.0</span></b>
</div>
</div>
</main>
);
}07 波の渦 — 離岸流の渦パッチ — 計測 累積面積 60.735% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · キャンバス 海 アニメーション シーン9選 — 重ねた正弦波で波の場を計算する
このデモが属する記事
キャンバス 海 アニメーション シーン9選 — 重ねた正弦波で波の場を計算する海 アニメーションは写真やGIFを繰り返し再生する画面ではなく、格子のマスごとに波の高さをリアルタイムで計算し直して描く画面です。重ねた正弦波で波の場を計算するという原理ひとつの上に、この9つのシーンは水色・泡・反射光・渦へと枝分かれしてい この記事のデモをすべて見る


