リサージュ曲線 作り方9選 — 波の干渉で描くキャンバス
リサージュ曲線とは、周波数の異なる二つのサイン波をX軸とY軸にそれぞれ与えたときにペンが描く閉曲線で、オシロスコープの画面で位相差を目で確かめるために使われる図形です。ここではその原理をMath.sinとキャンバスだけで九つの場面に広げ、すべてマイクなしでも動きます。
01 wave-lissajous-xy-scope
02 wave-ripple-interference-field
03 wave-standing-string
04 wave-chladni-sand-pattern
05 wave-beat-envelope
06 wave-polar-rose-lissajous
07 wave-moire-grating
08 wave-phase-diff-panel
09 wave-membrane-mic-input
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 リサージュ XY オシロスコープ
- 02 二点波源の干渉フィールド
- 03 定常波なわとびハーモニクス
- 04 クラドニ図形の砂模様
- 05 うなり振幅エンベロープ
- 06 極座標ローズ・リサージュ
- 07 モアレ格子干渉
- 08 位相差比較パネル
- 09 円形膜の振動モード(マイク入力)
順番は難易度順ではなく、波が重なる仕組みがだんだん複雑になる順です。最初の三つは一つの波の形を描く基礎(01は二軸の合成、02は二つの点波源の和、03は反射で生まれる定在波)、真ん中の三つは重なりの結果を模様として見せる系統(04は節線に砂が積もるクラドニ図形、05は周波数差が作るうなり、06は同じ式を極座標に回したローズ)、最後の三つは、こうした波の模様を画面のさまざまな場所へ持ち込む応用です(07は格子二枚が作るモアレ、08は位相差スライダー、09はマイクの音を乗せる膜)。波の干渉 シミュレーションを探して辿り着いた方は02が、画面に残る一枚を選ぶなら04か07が先に目に入ります。キャンバスのピクセルをまるごと動かす場面が欲しいならhtml5 canvas アニメーション9種 — 水滴とブラックホールが隣にあり、粒子として散らばる側はパーティクル アニメーション canvas 9種 — ライブラリなしの作り方、音を可視化する軸はオーディオビジュアライザー 作り方 9選 — 棒からマイクまでへと続きます。
01リサージュ XY オシロスコープ
二つのサイン波をX・Yに与え、ペンが描く軌跡を残像とともに見せる、オシロスコープの画面をそのまま写した場面です。「リサージュ曲線 オシロスコープ」で検索して辿り着いた方に一番近い形です。

<main class="stage">
<div class="ls" id="ls">
<p class="ls__hint">
<svg class="ls__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 40H40a24 24 0 0 0-24 24v128a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24ZM40 56h176a8 8 0 0 1 8 8v24H32V64a8 8 0 0 1 8-8Zm176 144H40a8 8 0 0 1-8-8V104h192v88a8 8 0 0 1-8 8Z"/><circle cx="56" cy="72" r="8"/><circle cx="84" cy="72" r="8"/><path d="M128 120a40 40 0 1 0 40 40a40 40 0 0 0-40-40Zm0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24Z"/></g></svg>
<span>2つのサイン波は3:2の周波数比 — 曲線が生きて動きます</span>
</p>
<div class="ls__field" id="field" role="application" aria-label="リサージュ曲線の実験盤">
<canvas class="ls__cv" id="cv"></canvas>
<b class="ls__badge" aria-hidden="true"><span class="ls__badgeNum">3:2</span><span class="ls__badgeUnit">周波数比</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 리사주 XY 오실로스코프 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·배지만 맡는다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개라야 렌더러가 실시간으로 프레임을 찍어
// 캔버스가 실제로 도는 모습이 담긴다(도구/demo.py 렌더(), 077 선례).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ls {
--scope-bg: #{$stage-ink};
--trace: #{$subject-mint};
--grid: #{rgba($subject-cream, .14)};
--badge-bg: #{rgba($subject-cream, .12)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ls__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-cream;
}
.ls__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-mint; }
.ls__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: var(--scope-bg);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.ls__cv { display: block; width: 100%; height: 100%; }
.ls__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.ls__field { height: 124px; }
.ls__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ls {
--scope-bg: #17141a;
--trace: #4cd4a6;
--grid: rgba(255, 247, 230, 0.14);
--badge-bg: rgba(255, 247, 230, 0.12);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ls__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: #fff7e6;
}
.ls__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #4cd4a6;
}
.ls__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: var(--scope-bg);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.ls__cv {
display: block;
width: 100%;
height: 100%;
}
.ls__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.ls__field {
height: 124px;
}
.ls__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ls.is-still .ls__badge {
opacity: 0.92 !important;
}
}// 리사주 XY 오실로스코프 — X 축·Y 축에 각기 다른 주파수의 사인파를 걸면 펜이 그리는 자취가
// 리사주 곡선이 된다. 매 프레임 Math.sin 으로 좌표를 계산해 점을 찍고,
// globalCompositeOperation(destination-out) 으로 이전 프레임을 살짝 지워 잔상 트레일을 남긴다.
var root = document.getElementById('ls');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
// willReadFrequently 를 안 쓰는 대신 매 프레임 전체를 다시 칠하는 방식(잔상은 destination-out)으로
// getImageData 왕복 없이 돈다 — 픽셀 계산은 02·04·07 번이 맡는다.
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var TRACE = css.getPropertyValue('--trace').trim();
var GRID = css.getPropertyValue('--grid').trim();
var BGN = css.getPropertyValue('--scope-bg').trim();
var FX = 3, FY = 2; // X·Y 주파수 비 — 3:2 로 두면 곡선이 닫히는 데 2 초가 걸린다
var LOOP = 2; // 곡선 한 바퀴(초). 캡처 창(2초)과 같아 gif 가 이어진다
var TRAIL = 0.10; // 매 프레임 지우는 양(알파) — 작게 두면 잔상이 길게 늘어진다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var phase = 0; // 슬라이더·자동 루프가 공유하는 위상(라디안)
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);
}
// 눈금 — 오실로스코프 화면의 중앙 십자·가는 격자. 곡선이 어디를 지나는지의 기준이 된다
function grid() {
ctx.strokeStyle = GRID;
ctx.lineWidth = 1;
ctx.beginPath();
var step = 24;
for (var x = step; x < W; x += step) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (var y = step; y < H; y += step) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
ctx.strokeStyle = GRID;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(W * 0.5, 0); ctx.lineTo(W * 0.5, H);
ctx.moveTo(0, H * 0.5); ctx.lineTo(W, H * 0.5);
ctx.stroke();
}
// 리사주 점 하나 — X 는 sin(FX·θ), Y 는 sin(FY·θ+phase). 펜이 이 점을 계속 따라다닌다
function point(th) {
var a = Math.min(W, H) * 0.36;
return {
x: W * 0.5 + Math.sin(th * FX) * a,
y: H * 0.5 + Math.sin(th * FY + phase) * a
};
}
function drawTrail(sec) {
// 잔상 — destination-out 으로 살짝 지운 뒤 격자 위에 자취를 다시 그린다.
// 곡선 전체(θ 0→2π)를 얇게 깔고 펜 머리만 진하게 밝힌다
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,' + TRAIL + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
grid();
var N = 160; // 곡선 조각 수 — 부드러우면 충분하다
ctx.strokeStyle = TRACE;
ctx.lineWidth = 1.4;
ctx.globalAlpha = 0.5;
ctx.beginPath();
for (var i = 0; i <= N; i++) {
var p = point(i / N * Math.PI * 2);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// 펜 머리 — 자초 위를 도는 밝은 점. sec 를 그대로 위상으로 쓴다(결정적)
var t = (sec % LOOP) / LOOP * Math.PI * 2;
var hp = point(t);
ctx.fillStyle = TRACE;
ctx.beginPath();
ctx.arc(hp.x, hp.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.beginPath();
ctx.arc(hp.x, hp.y, 2, 0, Math.PI * 2);
ctx.fill();
}
// 위상을 천천히 밀어 곡선 모양이 살아 움직이게 한다 — 같은 3:2 여도 위상이 바뀌면 기울어진다
function frame(now) {
var sec = now / 1000;
phase = sec / LOOP * Math.PI * 0.5;
drawTrail(sec);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) drawTrail(0.3); });
if (reduce) {
// reduce — 루프를 돌리지 않고 위상 0.3의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
phase = 0.3;
drawTrail(0.3);
} else {
raf = requestAnimationFrame(frame);
}// LissajousXyScope — vanilla 판(run/303/demo/01-wave-lissajous-xy-scope/index.html) 의 React 포트.
// X 축·Y 축에 각기 다른 주파수의 사인파를 걸어 펜이 그리는 리사주 곡선을
// 캔버스 rAF 루프(Math.sin + destination-out 잔상)로 그린다. CSS 애니메이션 0개.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./LissajousXyScope.module.scss";
export type LissajousXyScopeProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TITLE = "리사주 XY 오실로스코프 데모";
const HINT = "두 사인파의 주파수 비 3:2 — 곡선이 살아 움직입니다";
const FIELD_LABEL = "리사주 곡선 실험판";
const BADGE_NUM = "3:2";
const BADGE_UNIT = "주파수비";
const FX = 3; // X·Y 주파수 비 — 3:2 로 두면 곡선이 닫히는 데 2 초가 걸린다
const LOOP = 2; // 곡선 한 바퀴(초)
const TRAIL = 0.10; // 매 프레임 지우는 양(알파) — 작게 두면 잔상이 길게 늘어진다
export default function LissajousXyScope({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#4cd4a6",
}: LissajousXyScopeProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
// willReadFrequently 를 안 쓰는 대신 매 프레임 전체를 다시 칠하는 방식(잔상은 destination-out)으로
// getImageData 왕복 없이 돈다.
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const TRACE = css.getPropertyValue("--trace").trim();
const GRID = css.getPropertyValue("--grid").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
let phase = 0; // 자동 루프가 밀어가는 위상(라디안)
function fit() {
const 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);
}
// 눈금 — 오실로스코프 화면의 중앙 십자·가는 격자. 곡선이 어디를 지나는지의 기준이 된다
function grid() {
ctx.strokeStyle = GRID;
ctx.lineWidth = 1;
ctx.beginPath();
const step = 24;
for (let x = step; x < W; x += step) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (let y = step; y < H; y += step) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
ctx.strokeStyle = GRID;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(W * 0.5, 0); ctx.lineTo(W * 0.5, H);
ctx.moveTo(0, H * 0.5); ctx.lineTo(W, H * 0.5);
ctx.stroke();
}
// 리사주 점 하나 — X 는 sin(FX·θ), Y 는 sin(FY·θ+phase). 펜이 이 점을 계속 따라다닌다
function point(th: number) {
const a = Math.min(W, H) * 0.36;
return {
x: W * 0.5 + Math.sin(th * FX) * a,
y: H * 0.5 + Math.sin(th * 2 + phase) * a,
};
}
function drawTrail(sec: number) {
// 잔상 — destination-out 으로 살짝 지운 뒤 격자 위에 자취를 다시 그린다.
// 곡선 전체(θ 0→2π)를 얇게 깔고 펜 머리만 진하게 밝힌다
ctx.globalCompositeOperation = "destination-out";
ctx.fillStyle = "rgba(0,0,0," + TRAIL + ")";
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "source-over";
grid();
const N = 160; // 곡선 조각 수 — 부드러우면 충분하다
ctx.strokeStyle = TRACE;
ctx.lineWidth = 1.4;
ctx.globalAlpha = 0.5;
ctx.beginPath();
for (let i = 0; i <= N; i++) {
const p = point((i / N) * Math.PI * 2);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.globalAlpha = 1;
// 펜 머리 — 자취 위를 도는 밝은 점. sec 를 그대로 위상으로 쓴다(결정적)
const t = ((sec % LOOP) / LOOP) * Math.PI * 2;
const hp = point(t);
ctx.fillStyle = TRACE;
ctx.beginPath();
ctx.arc(hp.x, hp.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.85)";
ctx.beginPath();
ctx.arc(hp.x, hp.y, 2, 0, Math.PI * 2);
ctx.fill();
}
// 위상을 천천히 밀어 곡선 모양이 살아 움직이게 한다 — 같은 3:2 여도 위상이 바뀌면 기울어진다
function frame(now: number) {
const sec = now / 1000;
phase = (sec / LOOP) * Math.PI * 0.5;
drawTrail(sec);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) drawTrail(0.3);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프를 돌리지 않고 위상 0.3의 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
phase = 0.3;
drawTrail(0.3);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ls} style={vars} ref={rootRef}>
<p className={styles["ls__hint"]}>
<svg className={styles["ls__ico"]} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M216 40H40a24 24 0 0 0-24 24v128a24 24 0 0 0 24 24h176a24 24 0 0 0 24-24V64a24 24 0 0 0-24-24ZM40 56h176a8 8 0 0 1 8 8v24H32V64a8 8 0 0 1 8-8Zm176 144H40a8 8 0 0 1-8-8V104h192v88a8 8 0 0 1-8 8Z" />
<circle cx="56" cy="72" r="8" />
<circle cx="84" cy="72" r="8" />
<path d="M128 120a40 40 0 1 0 40 40a40 40 0 0 0-40-40Zm0 64a24 24 0 1 1 24-24a24 24 0 0 1-24 24Z" />
</g>
</svg>
<span>{HINT}</span>
</p>
<div className={styles["ls__field"]} role="application" aria-label={FIELD_LABEL} ref={fieldRef}>
<canvas className={styles["ls__cv"]} ref={cvRef} aria-label={TITLE} />
<b className={styles["ls__badge"]} aria-hidden="true">
<span className={styles["ls__badgeNum"]}>{BADGE_NUM}</span>
<span className={styles["ls__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
</div>
</main>
);
}01 · リサージュ XY オシロスコープ — 計測 累積面積 16.829% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ペンの座標はどうMath.sinの二行で決まるのか
var FX = 3, FY = 2; // X・Y 周波数比 — 3:2 にすると曲線が閉じるまで2秒かかる
function point(th) {
var a = Math.min(W, H) * 0.36;
return {
x: W * 0.5 + Math.sin(th * FX) * a,
y: H * 0.5 + Math.sin(th * FY + phase) * a
};
}
残像はなぜdestination-outで消しながら残るのか
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,' + TRAIL + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
XはsinのFX倍、Yはsinの位相付きFY倍なので3:2比の閉曲線が位相に応じて傾きながら動き、MDNのglobalCompositeOperation文書が説明するdestination-outが毎フレーム透明度0.10ずつ消して残像を残します。累積面積16.83%・23/23フレームで、九つの中では動きの静かな部類に入ります。
02二重点波源 干渉フィールド
二点から広がる円形波が出会って強め合ったり弱め合ったりする模様を、ピクセルごとに距離を測って直接計算する波の干渉場面です。

<main class="stage">
<div class="ri" id="ri">
<p class="ri__hint">
<svg class="ri__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24Zm0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88Z" opacity=".35"/><path d="M128 56a72 72 0 1 0 72 72a72.08 72.08 0 0 0-72-72Zm0 128a56 56 0 1 1 56-56a56.06 56.06 0 0 1-56 56Z" opacity=".6"/><circle cx="128" cy="128" r="24"/></g></svg>
<span>2つの波源の波が出会って明るくなる縞 — 干渉縞です</span>
</p>
<div class="ri__field" id="field" role="application" aria-label="波の干渉の実験盤">
<canvas class="ri__cv" id="cv"></canvas>
<b class="ri__badge" aria-hidden="true"><span class="ri__badgeNum">2</span><span class="ri__badgeUnit">点波源</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 이중 점파원 간섭 필드 — 두 점원에서 퍼지는 원형 파동의 겹침을 픽셀 단위로 계산한다.
// 움직임은 전부 캔버스 rAF 루프(getImageData/putImageData) 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
.ri {
--field-bg: #{$stage-ink};
--ink-line: #{$subject-sky};
--halo: #{$subject-mint};
--badge-bg: #{rgba($subject-cream, .12)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ri__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-cream;
}
.ri__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-sky; }
.ri__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: var(--field-bg);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.ri__cv { display: block; width: 100%; height: 100%; }
.ri__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.ri__field { height: 124px; }
.ri__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ri.is-still .ri__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ri {
--field-bg: #17141a;
--ink-line: #6ec8ff;
--halo: #4cd4a6;
--badge-bg: rgba(255, 247, 230, 0.12);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ri__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: #fff7e6;
}
.ri__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #6ec8ff;
}
.ri__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: var(--field-bg);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.ri__cv {
display: block;
width: 100%;
height: 100%;
}
.ri__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.ri__field {
height: 124px;
}
.ri__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ri.is-still .ri__badge {
opacity: 0.92 !important;
}
}// 이중 点波源 간섭 필드 — 매 픽셀에서 두 점원까지의 거리를 Math.hypot 으로 잰 뒤
// 두 파동의 사인 합으로 진폭을 계산한다. 위상이 맞는 곳(보강 간섭)은 밝게,
// 어긋나는 곳(상쇄 간섭)은 어두운 쌍곡선 줄이 된다.
var root = document.getElementById('ri');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
// willReadFrequently — 매 프레임 putImageData 로 되돌리는 캔버스는 이 힌트로 GPU 왕복을 줄인다
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var INK = css.getPropertyValue('--ink-line').trim();
var HALO = css.getPropertyValue('--halo').trim();
var BGN = css.getPropertyValue('--field-bg').trim();
var LOOP = 2; // 파동 한 주기(초). 캡처 창과 같아 gif 가 이어진다
var WAVE = 26; // 파장(CSS px) — 짧을수록 무늬가 촘촘해진다
var STEP = 2; // 계산 픽셀 간격 — 2px 씩 계산해 4픽셀 뭉치로 찍는다(속도)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var img = null, buf = null; // createImageData 로 만든 프레임 버퍼
// 색을 [r,g,b] 로 미리 풀어둔다 — 매 픽셀마다 파싱하면 느리다
function parse(c) {
var m = c.match(/^#([0-9a-f]{6})$/i);
if (!m) return [110, 200, 255];
return [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16)];
}
var C1 = parse(INK);
var C2 = parse(HALO);
var BGC = parse(BGN);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = Math.round(r.width); H = Math.round(r.height);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
img = ctx.createImageData(W, H);
buf = img.data;
}
// 두 점원의 위치 — 화면 폭의 32% 지점에 좌우로 마주 본다
function sources() {
return [
{ x: W * 0.32, y: H * 0.5 },
{ x: W * 0.68, y: H * 0.5 }
];
}
// 간섭 계산 — 픽셀마다 sin(d/λ·2π − t·2π) 두 개를 더해 진폭(-2..2)을 밝기로 눕힌다
function interference(sec) {
var s = sources();
var t = (sec % LOOP) / LOOP * Math.PI * 2;
var k = Math.PI * 2 / WAVE;
var cx1 = s[0].x, cy1 = s[0].y, cx2 = s[1].x, cy2 = s[1].y;
for (var y = 0; y < H; y++) {
for (var x = 0; x < W; x++) {
var d1 = Math.hypot(x - cx1, y - cy1);
var d2 = Math.hypot(x - cx2, y - cy2);
// 보강(+)·상쇄(-)를 같은 비율로 두고 0.5 오프셋으로 밝기 축에 올린다
var a1 = Math.sin(k * d1 - t);
var a2 = Math.sin(k * d2 - t);
var v = (a1 + a2) * 0.25 + 0.5; // 0..1
var i = (y * W + x) * 4;
// 파랑(바다) → 민트(고 에너지) 두 색을 v 로 섞는다 — 파스텔 톤 안에서 대비를 만든다
buf[i] = Math.round(C1[0] + (C2[0] - C1[0]) * v);
buf[i + 1] = Math.round(C1[1] + (C2[1] - C1[1]) * v);
buf[i + 2] = Math.round(C1[2] + (C2[2] - C1[2]) * v);
buf[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// 点波源 두 개 표시 — 흰 점과 얇은 고리
for (var j = 0; j < 2; j++) {
ctx.fillStyle = '#fff7e6';
ctx.beginPath();
ctx.arc(s[j].x, s[j].y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,247,230,0.5)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(s[j].x, s[j].y, 7, 0, Math.PI * 2);
ctx.stroke();
}
}
function frame(now) {
interference(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) interference(0.25); });
if (reduce) {
// reduce — 루프 없이 위상 0.25의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
interference(0.25);
} else {
raf = requestAnimationFrame(frame);
}// RippleInterferenceField — vanilla 판(run/303/demo/02-wave-ripple-interference-field/index.html) 의 React 포트.
// 매 픽셀에서 두 점원까지의 거리를 Math.hypot 으로 잰 뒤 두 파동의 사인 합으로 진폭을
// 계산한다. 위상이 맞는 곳(보강)은 밝게, 어긋나는 곳(상쇄)은 어두운 쌍곡선 줄이 된다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RippleInterferenceField.module.scss";
export type RippleInterferenceFieldProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TITLE = "이중 점파원 간섭 필드 데모";
const HINT = "두 점원의 파동이 만나 밝아지는 줄 — 간섭무늬입니다";
const FIELD_LABEL = "파동 간섭 실험판";
const BADGE_NUM = "2";
const BADGE_UNIT = "점파원";
const LOOP = 2; // 파동 한 주기(초)
const WAVE = 26; // 파장(CSS px) — 짧을수록 무늬가 촘촘해진다
// 색을 [r,g,b] 로 미리 풀어둔다 — 매 픽셀마다 파싱하면 느리다
function parse(c: string): [number, number, number] {
const m = c.match(/^#([0-9a-f]{6})$/i);
if (!m) return [110, 200, 255];
return [
parseInt(m[1].slice(0, 2), 16),
parseInt(m[1].slice(2, 4), 16),
parseInt(m[1].slice(4, 6), 16),
];
}
export default function RippleInterferenceField({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#6ec8ff",
}: RippleInterferenceFieldProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
// willReadFrequently — 매 프레임 putImageData 로 되돌리는 캔버스는 이 힌트로 GPU 왕복을 줄인다
const ctx0 = cv0.getContext("2d", { willReadFrequently: true });
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const INK = css.getPropertyValue("--ink-line").trim();
const HALO = css.getPropertyValue("--halo").trim();
const C1 = parse(INK);
const C2 = parse(HALO);
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
let img: ImageData | null = null;
let buf: Uint8ClampedArray | null = null; // createImageData 로 만든 프레임 버퍼
function fit() {
const r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = Math.round(r.width); H = Math.round(r.height);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
img = ctx.createImageData(W, H);
buf = img.data;
}
// 두 점원의 위치 — 화면 폭의 32% 지점에 좌우로 마주 본다
function sources() {
return [
{ x: W * 0.32, y: H * 0.5 },
{ x: W * 0.68, y: H * 0.5 },
];
}
// 간섭 계산 — 픽셀마다 sin(d/λ·2π − t·2π) 두 개를 더해 진폭(-2..2)을 밝기로 눕힌다
function interference(sec: number) {
if (!img || !buf) return;
const s = sources();
const t = ((sec % LOOP) / LOOP) * Math.PI * 2;
const k = (Math.PI * 2) / WAVE;
const cx1 = s[0].x, cy1 = s[0].y, cx2 = s[1].x, cy2 = s[1].y;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const d1 = Math.hypot(x - cx1, y - cy1);
const d2 = Math.hypot(x - cx2, y - cy2);
// 보강(+)·상쇄(-)를 같은 비율로 두고 0.5 오프셋으로 밝기 축에 올린다
const a1 = Math.sin(k * d1 - t);
const a2 = Math.sin(k * d2 - t);
const v = (a1 + a2) * 0.25 + 0.5; // 0..1
const i = (y * W + x) * 4;
// 파랑(바다) → 민트(고 에너지) 두 색을 v 로 섞는다 — 파스텔 톤 안에서 대비를 만든다
buf[i] = Math.round(C1[0] + (C2[0] - C1[0]) * v);
buf[i + 1] = Math.round(C1[1] + (C2[1] - C1[1]) * v);
buf[i + 2] = Math.round(C1[2] + (C2[2] - C1[2]) * v);
buf[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
// 점파원 두 개 표시 — 흰 점과 얇은 고리
for (let j = 0; j < 2; j++) {
ctx.fillStyle = "#fff7e6";
ctx.beginPath();
ctx.arc(s[j].x, s[j].y, 3, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = "rgba(255,247,230,0.5)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(s[j].x, s[j].y, 7, 0, Math.PI * 2);
ctx.stroke();
}
}
function frame(now: number) {
interference(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) interference(0.25);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 위상 0.25의 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
interference(0.25);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ri} style={vars} ref={rootRef}>
<p className={styles["ri__hint"]}>
<svg className={styles["ri__ico"]} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M128 24a104 104 0 1 0 104 104A104.11 104.11 0 0 0 128 24Zm0 192a88 88 0 1 1 88-88a88.1 88.1 0 0 1-88 88Z" opacity=".35" />
<path d="M128 56a72 72 0 1 0 72 72a72.08 72.08 0 0 0-72-72Zm0 128a56 56 0 1 1 56-56a56.06 56.06 0 0 1-56 56Z" opacity=".6" />
<circle cx="128" cy="128" r="24" />
</g>
</svg>
<span>{HINT}</span>
</p>
<div className={styles["ri__field"]} role="application" aria-label={FIELD_LABEL} ref={fieldRef}>
<canvas className={styles["ri__cv"]} ref={cvRef} aria-label={TITLE} />
<b className={styles["ri__badge"]} aria-hidden="true">
<span className={styles["ri__badgeNum"]}>{BADGE_NUM}</span>
<span className={styles["ri__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
</div>
</main>
);
}02 · 二点波源の干渉フィールド — 計測 累積面積 54.069% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ピクセルごとにMath.hypotを二回呼ぶ
var d1 = Math.hypot(x - cx1, y - cy1);
var d2 = Math.hypot(x - cx2, y - cy2);
var a1 = Math.sin(k * d1 - t);
var a2 = Math.sin(k * d2 - t);
var v = (a1 + a2) * 0.25 + 0.5; // 0..1
結果はputImageDataで一度に描く
var i = (y * W + x) * 4;
buf[i] = Math.round(C1[0] + (C2[0] - C1[0]) * v);
buf[i + 1] = Math.round(C1[1] + (C2[1] - C1[1]) * v);
buf[i + 2] = Math.round(C1[2] + (C2[2] - C1[2]) * v);
buf[i + 3] = 255;
ctx.putImageData(img, 0, 0);
距離dでの波の位相はsin(d/λ·2π−t)で、二つの波を足した振幅を明るさに置き換えると、MDNのMath.hypot文書の距離関数一つで物理の教科書にある干渉模様がそのまま現れます。累積面積54.07%・23/23フレームで、九つの中で二番目に広く動きます。
03定在波 弦のハーモニクス
固定された両端の弦が第一・第二・第三ハーモニクスへ順に切り替わり、節が増えていく、ギター弦の原理を見せる場面です。

<main class="stage">
<div class="ss" id="ss">
<p class="ss__hint">
<svg class="ss__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M32 120h192a8 8 0 0 0 0-16H32a8 8 0 0 0 0 16Z"/><path d="M224 136H32a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16Z"/><path d="M128 88a44 44 0 0 0-44 44a8 8 0 0 0 8 8h72a8 8 0 0 0 8-8a44 44 0 0 0-44-44Zm-26.91 36A28 28 0 0 1 128 104a28 28 0 0 1 26.91 20Z"/><circle cx="52" cy="60" r="12"/><circle cx="204" cy="60" r="12"/></g></svg>
<span>固定された両端の間 — ハーモニクスが順に変わる弦の定常波</span>
</p>
<div class="ss__field" id="field" role="application" aria-label="定常波の弦振動の実験盤">
<canvas class="ss__cv" id="cv"></canvas>
<b class="ss__badge" aria-hidden="true"><span class="ss__badgeNum">3</span><span class="ss__badgeUnit">次ハーモニクス</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 정상파 줄넘기 하모닉 — 1~N 차 하모닉 사인 합성으로 줄 모양을 그린다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-paper);
.ss {
--string: #{$subject-lilac};
--node: #{$subject-blue};
--rail: #{rgba($subject-ink, .14)};
--ink-fg: #{$subject-ink};
--badge-bg: #{rgba($subject-ink, .78)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ss__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: var(--ink-fg);
}
.ss__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-lilac; }
.ss__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.ss__cv { display: block; width: 100%; height: 100%; }
.ss__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.ss__field { height: 124px; }
.ss__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ss.is-still .ss__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);
}
.ss {
--string: #b9a0ff;
--node: #2f6df6;
--rail: rgba(23, 20, 26, 0.14);
--ink-fg: #17141a;
--badge-bg: rgba(23, 20, 26, 0.78);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ss__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: var(--ink-fg);
}
.ss__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.ss__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.ss__cv {
display: block;
width: 100%;
height: 100%;
}
.ss__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.ss__field {
height: 124px;
}
.ss__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ss.is-still .ss__badge {
opacity: 0.92 !important;
}
}// 정상파 줄넘기 하모닉 — 양 끝이 고정된 줄 위의 정상파는 sin(nπx/L) 꼴이다.
// 1차·2차·3차 하모닉을 시간이 지나며 차례로 지나가게 하고, 마디(node) 위치에 고정 점을 찍는다.
// 매 프레임 lineTo 로 줄을 다시 그린다 — 속성 트윈이 아니라 계산으로 그린다.
var root = document.getElementById('ss');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var STRING = css.getPropertyValue('--string').trim();
var NODE = css.getPropertyValue('--node').trim();
var RAIL = css.getPropertyValue('--rail').trim();
var INK = css.getPropertyValue('--ink-fg').trim();
var LOOP = 2; // 하모닉 하나가 머무는 시간 포함 전체 사이클(초)
var SEG = 120; // 줄을 긋는 조각 수
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var numEl = document.querySelector('.ss__badgeNum');
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);
}
// 지금 시각에 해당하는 하모닉 차수와 진폭 — 1→2→3차를 각각 머물렀다가 다음으로 넘어간다
function modeAt(sec) {
var u = (sec % LOOP) / LOOP; // 0..1 사이클
var n = 1 + Math.floor(u * 3); // 1, 2, 3차
if (n > 3) n = 3;
return n;
}
// 정상파 줄 모양 — sin(nπs) 를 s(0..1)를 따라 그린다. 진폭은 하모닉이 커질수록 줄어든다(에너지 보존 느낌)
function drawString(sec) {
var n = modeAt(sec);
var t = (sec % LOOP) / LOOP * Math.PI * 2 * 3; // 진동 시계 — 하모닉 3개를 한 사이클로
var x0 = W * 0.08, x1 = W * 0.92;
var mid = H * 0.5;
var amp = H * 0.3 / n;
ctx.clearRect(0, 0, W, H);
// 레일 — 줄이 얹힌 고정 지지대
ctx.strokeStyle = RAIL;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0 - 10, mid + 0.5); ctx.lineTo(x1 + 10, mid + 0.5);
ctx.stroke();
// 줄 — sin(nπs)·cos(ωt). 공간 부분과 시간 부분이 곱해진 것이 정상파의 정의다
ctx.strokeStyle = STRING;
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.beginPath();
for (var i = 0; i <= SEG; i++) {
var s = i / SEG;
var x = x0 + (x1 - x0) * s;
var y = mid - Math.sin(Math.PI * n * s) * amp * Math.cos(t);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 마디(node) — sin(nπs)=0 인 지점 n−1 개. 줄이 흔들려도 절대 움직이지 않는 점이다
ctx.fillStyle = NODE;
for (var k = 1; k < n; k++) {
var nx = x0 + (x1 - x0) * (k / n);
ctx.beginPath();
ctx.arc(nx, mid, 4, 0, Math.PI * 2);
ctx.fill();
}
// 고정단 — 양 끝의 핀
ctx.fillStyle = INK;
ctx.beginPath(); ctx.arc(x0, mid, 5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x1, mid, 5, 0, Math.PI * 2); ctx.fill();
if (numEl && numEl.textContent !== String(n)) numEl.textContent = String(n);
}
function frame(now) {
drawString(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) drawString(0.1); });
if (reduce) {
// reduce — 루프 없이 1次ハーモニクス 최대 진폭의 한 장만 그린다
root.classList.add('is-still');
drawString(0.1);
} else {
raf = requestAnimationFrame(frame);
}// StandingString — vanilla 판(run/303/demo/03-wave-standing-string/index.html) 의 React 포트.
// 양 끝이 고정된 줄 위의 정상파는 sin(nπx/L) 꼴이다. 1·2·3차 하모닉을 차례로 지나가게 하고
// 마디(node) 위치에 고정 점을 찍는다. 매 프레임 lineTo 로 줄을 다시 그린다 — 계산으로 그린다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./StandingString.module.scss";
export type StandingStringProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TITLE = "정상파 줄넘기 하모닉 데모";
const HINT = "고정된 양 끝 사이 — 하모닉이 차례로 바뀌는 줄의 정상파";
const FIELD_LABEL = "정상파 줄 진동 실험판";
const BADGE_NUM = "1";
const BADGE_UNIT = "차 하모닉";
const LOOP = 2; // 하모닉 하나가 머무는 시간 포함 전체 사이클(초)
const SEG = 120; // 줄을 긋는 조각 수
export default function StandingString({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#b9a0ff",
}: StandingStringProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const numRef = useRef<HTMLSpanElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
const numEl = numRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const STRING = css.getPropertyValue("--string").trim();
const NODE = css.getPropertyValue("--node").trim();
const RAIL = css.getPropertyValue("--rail").trim();
const INK = css.getPropertyValue("--ink-fg").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
function fit() {
const 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);
}
// 지금 시각에 해당하는 하모닉 차수 — 1→2→3차를 각각 머물렀다가 다음으로 넘어간다
function modeAt(sec: number) {
const u = (sec % LOOP) / LOOP; // 0..1 사이클
let n = 1 + Math.floor(u * 3); // 1, 2, 3차
if (n > 3) n = 3;
return n;
}
// 정상파 줄 모양 — sin(nπs) 를 s(0..1)를 따라 그린다. 진폭은 하모닉이 커질수록 줄어든다(에너지 보존 느낌)
function drawString(sec: number) {
const n = modeAt(sec);
const t = ((sec % LOOP) / LOOP) * Math.PI * 2 * 3; // 진동 시계 — 하모닉 3개를 한 사이클로
const x0 = W * 0.08, x1 = W * 0.92;
const mid = H * 0.5;
const amp = (H * 0.3) / n;
ctx.clearRect(0, 0, W, H);
// 레일 — 줄이 얹힌 고정 지지대
ctx.strokeStyle = RAIL;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x0 - 10, mid + 0.5); ctx.lineTo(x1 + 10, mid + 0.5);
ctx.stroke();
// 줄 — sin(nπs)·cos(ωt). 공간 부분과 시간 부분이 곱해진 것이 정상파의 정의다
ctx.strokeStyle = STRING;
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.beginPath();
for (let i = 0; i <= SEG; i++) {
const s = i / SEG;
const x = x0 + (x1 - x0) * s;
const y = mid - Math.sin(Math.PI * n * s) * amp * Math.cos(t);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 마디(node) — sin(nπs)=0 인 지점 n−1 개. 줄이 흔들려도 절대 움직이지 않는 점이다
ctx.fillStyle = NODE;
for (let k = 1; k < n; k++) {
const nx = x0 + (x1 - x0) * (k / n);
ctx.beginPath();
ctx.arc(nx, mid, 4, 0, Math.PI * 2);
ctx.fill();
}
// 고정단 — 양 끝의 핀
ctx.fillStyle = INK;
ctx.beginPath(); ctx.arc(x0, mid, 5, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x1, mid, 5, 0, Math.PI * 2); ctx.fill();
if (numEl && numEl.textContent !== String(n)) numEl.textContent = String(n);
}
function frame(now: number) {
drawString(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) drawString(0.1);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 1차 하모닉 최대 진폭의 한 장만 그린다
root.classList.add("is-still");
drawString(0.1);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.ss} style={vars} ref={rootRef}>
<p className={styles["ss__hint"]}>
<svg className={styles["ss__ico"]} viewBox="0 0 256 256" aria-hidden="true" focusable="false">
<g fill="currentColor">
<path d="M32 120h192a8 8 0 0 0 0-16H32a8 8 0 0 0 0 16Z" />
<path d="M224 136H32a8 8 0 0 0 0 16h192a8 8 0 0 0 0-16Z" />
<path d="M128 88a44 44 0 0 0-44 44a8 8 0 0 0 8 8h72a8 8 0 0 0 8-8a44 44 0 0 0-44-44Zm-26.91 36A28 28 0 0 1 128 104a28 28 0 0 1 26.91 20Z" />
<circle cx="52" cy="60" r="12" />
<circle cx="204" cy="60" r="12" />
</g>
</svg>
<span>{HINT}</span>
</p>
<div className={styles["ss__field"]} role="application" aria-label={FIELD_LABEL} ref={fieldRef}>
<canvas className={styles["ss__cv"]} ref={cvRef} aria-label={TITLE} />
<b className={styles["ss__badge"]} aria-hidden="true">
<span className={styles["ss__badgeNum"]} ref={numRef}>{BADGE_NUM}</span>
<span className={styles["ss__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
</div>
</main>
);
}03 · 定常波なわとびハーモニクス — 計測 累積面積 10.665% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
弦の形はsin(nπs)・cos(ωt)で決まる
for (var i = 0; i <= SEG; i++) {
var s = i / SEG;
var x = x0 + (x1 - x0) * s;
var y = mid - Math.sin(Math.PI * n * s) * amp * Math.cos(t);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
節はなぜ絶対に動かない点になるのか
ctx.fillStyle = NODE;
for (var k = 1; k < n; k++) {
var nx = x0 + (x1 - x0) * (k / n);
ctx.beginPath();
ctx.arc(nx, mid, 4, 0, Math.PI * 2);
ctx.fill();
}
定在波は空間部分sin(nπs)と時間部分cos(ωt)の積なので弦全体が同じ時計で振動し、sin(nπs)=0になる地点のn−1個は振幅が常に0なので揺れても動きません。
04クラドニ図形 砂模様パターン
振動板の上で砂が節線に沿って積もるクラドニ実験を、板全体のピクセルに定在波の式を当てはめて再現した場面です。

<main class="stage">
<div class="ch" id="ch">
<p class="ch__hint">
<svg class="ch__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="72" cy="72" r="10"/><circle cx="184" cy="72" r="10"/><circle cx="128" cy="128" r="10"/><circle cx="72" cy="184" r="10"/><circle cx="184" cy="184" r="10"/><path d="M40 128h176M128 40v176" stroke="currentColor" stroke-width="8" opacity=".35"/></g></svg>
<span>振動板の節線に沿って砂が自ら整列します</span>
</p>
<div class="ch__field" id="field" role="application" aria-label="クラドニ振動板の実験盤">
<canvas class="ch__cv" id="cv"></canvas>
<b class="ch__badge" aria-hidden="true"><span class="ch__badgeNum">3,4</span><span class="ch__badgeUnit">モード</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 클라드니 모래무늬 패턴 — 진동판의 마디선에 모래가 쌓이는 물리를 픽셀 계산으로 재현한다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
.ch {
--plate: #{$stage-ink};
--sand: #{$subject-cream};
--glow: #{$subject-mint};
--badge-bg: #{rgba($subject-ink, .78)};
--badge-fg: #{$subject-cream};
--ink-fg: #{$subject-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.ch__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: var(--ink-fg);
}
.ch__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-ink; }
.ch__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: var(--plate);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.ch__cv { display: block; width: 100%; height: 100%; }
.ch__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.ch__field { height: 124px; }
.ch__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ch.is-still .ch__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ch {
--plate: #17141a;
--sand: #fff7e6;
--glow: #4cd4a6;
--badge-bg: rgba(23, 20, 26, 0.78);
--badge-fg: #fff7e6;
--ink-fg: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.ch__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: var(--ink-fg);
}
.ch__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #17141a;
}
.ch__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: var(--plate);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.ch__cv {
display: block;
width: 100%;
height: 100%;
}
.ch__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.ch__field {
height: 124px;
}
.ch__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ch.is-still .ch__badge {
opacity: 0.92 !important;
}
}// 클라드니 모래무늬 패턴 — 진동판의 정상파 방정식 sin(nπx)·sin(mπy) − sin(mπx)·sin(nπy) 값이
// 0에 가까운(마디) 픽셀만 모래 입자로 남긴다. createImageData 로 프레임을 새로 만들고,
// Math.random 으로 입자 흩뿌림(잔떨림)을 더해 진짜 모래처럼 보이게 한다.
var root = document.getElementById('ch');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var SAND = css.getPropertyValue('--sand').trim();
var GLOW = css.getPropertyValue('--glow').trim();
var PLATE = css.getPropertyValue('--plate').trim();
var LOOP = 2; // モード 전환 한 사이클(초)
var MODES = [[2, 3], [3, 4], [4, 2], [5, 3]]; // (n, m) 하모닉 쌍 — 순서대로 지나간다
var numEl = document.querySelector('.ch__badgeNum');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var img = null, buf = null;
function parse(c) {
var m = c.match(/^#([0-9a-f]{6})$/i);
if (!m) return [255, 247, 230];
return [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16)];
}
var SANDC = parse(SAND);
var GLOWC = parse(GLOW);
var PLATEC = parse(PLATE);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = Math.round(r.width); H = Math.round(r.height);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
img = ctx.createImageData(W, H);
buf = img.data;
}
// 지금 시각의 (n, m) 쌍과 그 안에서의 진행률(0..1) — 진행률로 두 モード 사이를 부드럽게 섞는다
function modeAt(sec) {
var total = MODES.length * LOOP;
var u = (sec % total) / LOOP;
var idx = Math.floor(u) % MODES.length;
var next = (idx + 1) % MODES.length;
var f = u - Math.floor(u);
return { a: MODES[idx], b: MODES[next], f: f };
}
function chladni(x, y, n, m) {
return Math.sin(n * Math.PI * x) * Math.sin(m * Math.PI * y) -
Math.sin(m * Math.PI * x) * Math.sin(n * Math.PI * y);
}
function draw(sec) {
var mode = modeAt(sec);
var n = mode.a[0] + (mode.b[0] - mode.a[0]) * mode.f;
var m = mode.a[1] + (mode.b[1] - mode.a[1]) * mode.f;
for (var y = 0; y < H; y++) {
var ny = y / H;
for (var x = 0; x < W; x++) {
var nx = x / W;
var v = Math.abs(chladni(nx, ny, n, m));
var i = (y * W + x) * 4;
if (v < 0.05) {
// 마디에 가까울수록 모래가 진하다 — Math.random 으로 알갱이 흩뿌림을 더한다
var jitter = Math.random() * 0.35;
var t = Math.min(1, (0.05 - v) / 0.05 + jitter);
buf[i] = Math.round(PLATEC[0] + (SANDC[0] - PLATEC[0]) * t);
buf[i + 1] = Math.round(PLATEC[1] + (SANDC[1] - PLATEC[1]) * t);
buf[i + 2] = Math.round(PLATEC[2] + (SANDC[2] - PLATEC[2]) * t);
} else if (v < 0.12 && Math.random() < 0.06) {
// 마디 바깥으로 튄 낱알 몇 개 — 정적인 그림이 아니라 실제로 흩어지는 느낌을 준다
buf[i] = GLOWC[0]; buf[i + 1] = GLOWC[1]; buf[i + 2] = GLOWC[2];
} else {
buf[i] = PLATEC[0]; buf[i + 1] = PLATEC[1]; buf[i + 2] = PLATEC[2];
}
buf[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
var label = Math.round(mode.a[0]) + ',' + Math.round(mode.a[1]);
if (numEl && numEl.textContent !== label) numEl.textContent = label;
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.2); });
if (reduce) {
// reduce — 루프 없이 첫 モード 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.2);
} else {
raf = requestAnimationFrame(frame);
}// ChladniSandPattern — vanilla 판(run/303/demo/04-wave-chladni-sand-pattern/index.html) 의 React 포트.
// 클라드니 모래무늬 패턴 — 진동판의 정상파 방정식 sin(nπx)·sin(mπy) − sin(mπx)·sin(nπy) 값이
// 0에 가까운(마디) 픽셀만 모래 입자로 남긴다. createImageData 로 프레임을 새로 만들고,
// Math.random 으로 입자 흩뿌림(잔떨림)을 더해 진짜 모래처럼 보이게 한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ChladniSandPattern.module.scss";
export type ChladniSandPatternProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "진동판의 마디선을 따라 모래가 스스로 정렬됩니다";
const FIELD_LABEL = "클라드니 진동판 실험판";
const BADGE_UNIT = "모드";
const LOOP = 2; // 모드 전환 한 사이클(초)
const MODES: [number, number][] = [[2, 3], [3, 4], [4, 2], [5, 3]]; // (n, m) 하모닉 쌍 — 순서대로 지나간다
export default function ChladniSandPattern(props: ChladniSandPatternProps) {
const duration = props.duration ?? 2;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const accentColor = props.accentColor ?? "#4cd4a6";
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const [modeLabel, setModeLabel] = useState<string>("3,4");
const vars: Vars = { "--duration": duration + "s", "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const sand = css.getPropertyValue("--sand").trim();
const glow = css.getPropertyValue("--glow").trim();
const plate = css.getPropertyValue("--plate").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
let img: ImageData | null = null, buf: Uint8ClampedArray | null = null;
function parse(c: string): [number, number, number] {
const m = c.match(/^#([0-9a-f]{6})$/i);
if (!m) return [255, 247, 230];
return [
parseInt(m[1].slice(0, 2), 16),
parseInt(m[1].slice(2, 4), 16),
parseInt(m[1].slice(4, 6), 16),
];
}
const sandC = parse(sand);
const glowC = parse(glow);
const plateC = parse(plate);
function fit() {
const r = field!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = Math.round(r.width); H = Math.round(r.height);
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
img = ctx!.createImageData(W, H);
buf = img.data;
}
// 지금 시각의 (n, m) 쌍과 그 안에서의 진행률(0..1) — 진행률로 두 모드 사이를 부드럽게 섞는다
function modeAt(sec: number) {
const total = MODES.length * LOOP;
const u = (sec % total) / LOOP;
const idx = Math.floor(u) % MODES.length;
const next = (idx + 1) % MODES.length;
const f = u - Math.floor(u);
return { a: MODES[idx], b: MODES[next], f };
}
function chladni(x: number, y: number, n: number, m: number) {
return Math.sin(n * Math.PI * x) * Math.sin(m * Math.PI * y) -
Math.sin(m * Math.PI * x) * Math.sin(n * Math.PI * y);
}
function draw(sec: number) {
if (!buf) return;
const mode = modeAt(sec);
const n = mode.a[0] + (mode.b[0] - mode.a[0]) * mode.f;
const m = mode.a[1] + (mode.b[1] - mode.a[1]) * mode.f;
for (let y = 0; y < H; y++) {
const ny = y / H;
for (let x = 0; x < W; x++) {
const nx = x / W;
const v = Math.abs(chladni(nx, ny, n, m));
const i = (y * W + x) * 4;
if (v < 0.05) {
// 마디에 가까울수록 모래가 진하다 — Math.random 으로 알갱이 흩뿌림을 더한다
const jitter = Math.random() * 0.35;
const t = Math.min(1, (0.05 - v) / 0.05 + jitter);
buf[i] = Math.round(plateC[0] + (sandC[0] - plateC[0]) * t);
buf[i + 1] = Math.round(plateC[1] + (sandC[1] - plateC[1]) * t);
buf[i + 2] = Math.round(plateC[2] + (sandC[2] - plateC[2]) * t);
} else if (v < 0.12 && Math.random() < 0.06) {
// 마디 바깥으로 튄 낱알 몇 개 — 정적인 그림이 아니라 실제로 흩어지는 느낌을 준다
buf[i] = glowC[0]; buf[i + 1] = glowC[1]; buf[i + 2] = glowC[2];
} else {
buf[i] = plateC[0]; buf[i + 1] = plateC[1]; buf[i + 2] = plateC[2];
}
buf[i + 3] = 255;
}
}
ctx!.putImageData(img!, 0, 0);
const label = Math.round(mode.a[0]) + "," + Math.round(mode.a[1]);
setModeLabel((prev) => (prev !== label ? label : prev));
}
function frame(now: number) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) draw(0.2);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 첫 모드 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
draw(0.2);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
root.classList.remove("is-still");
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={styles.ch} style={vars}>
<p className={styles["ch__hint"]}>
<svg className={styles["ch__ico"]} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="72" cy="72" r="10"/><circle cx="184" cy="72" r="10"/><circle cx="128" cy="128" r="10"/><circle cx="72" cy="184" r="10"/><circle cx="184" cy="184" r="10"/><path d="M40 128h176M128 40v176" stroke="currentColor" stroke-width="8" opacity=".35"/></g></svg>
<span>{HINT}</span>
</p>
<div ref={fieldRef} className={styles["ch__field"]} role="application" aria-label={FIELD_LABEL}>
<canvas ref={cvRef} className={styles["ch__cv"]}></canvas>
<b className={styles["ch__badge"]} aria-hidden="true"><span className={styles["ch__badgeNum"]}>{modeLabel}</span><span className={styles["ch__badgeUnit"]}>{BADGE_UNIT}</span></b>
</div>
</div>
</main>
);
}04 · クラドニ図形の砂模様 — 計測 累積面積 32.851% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
節線はどこで二つのサイン積の差が0になるのか
function chladni(x, y, n, m) {
return Math.sin(n * Math.PI * x) * Math.sin(m * Math.PI * y) -
Math.sin(m * Math.PI * x) * Math.sin(n * Math.PI * y);
}
砂はなぜ値が0に近いところだけ濃く見えるのか
var v = Math.abs(chladni(nx, ny, n, m));
if (v < 0.05) {
var jitter = Math.random() * 0.35;
var t = Math.min(1, (0.05 - v) / 0.05 + jitter);
buf[i] = Math.round(PLATEC[0] + (SANDC[0] - PLATEC[0]) * t);
buf[i + 1] = Math.round(PLATEC[1] + (SANDC[1] - PLATEC[1]) * t);
buf[i + 2] = Math.round(PLATEC[2] + (SANDC[2] - PLATEC[2]) * t);
}
(n, m)の組が(2,3)→(3,4)→(4,2)→(5,3)へ移るとき二つのモードを進行率で混ぜて滑らかにつなぎ、Math.randomの散らばりが本物の砂粒のように見せます。強度平均106の濃いコントラストで節線がはっきりと浮かび上がります。
05うなり 振幅エンベロープ
周波数が1Hzだけ違う二つの波を足して大きくなったり消えたりする、うなりの様子を、合成波の上に点線の包絡線まで描いて見せる場面です。

<main class="stage">
<div class="be" id="be">
<p class="be__hint">
<svg class="be__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M24 128c16-64 40-64 56 0s40 64 56 0s40-64 56 0s40 64 56 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round"/></g></svg>
<span>わずかに周波数が違う2つの波が出会い、大きくなったり小さくなったりします</span>
</p>
<div class="be__field" id="field" role="application" aria-label="うなり波形の実験盤">
<canvas class="be__cv" id="cv"></canvas>
<b class="be__badge" aria-hidden="true"><span class="be__badgeNum">9·10</span><span class="be__badgeUnit">Hz</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 맥놀이 진폭 엔벨로프 — 주파수가 살짝 다른 두 사인파를 더하면 생기는 맥놀이(beat)를 그린다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.be {
--wave: #{$subject-blue};
--env: #{$subject-pink};
--rail: #{rgba($subject-ink, .14)};
--ink-fg: #{$subject-ink};
--badge-bg: #{rgba($subject-ink, .78)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.be__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: var(--ink-fg);
}
.be__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-pink; }
.be__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.be__cv { display: block; width: 100%; height: 100%; }
.be__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.be__field { height: 124px; }
.be__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.be.is-still .be__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);
}
.be {
--wave: #2f6df6;
--env: #ff9ebb;
--rail: rgba(23, 20, 26, 0.14);
--ink-fg: #17141a;
--badge-bg: rgba(23, 20, 26, 0.78);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.be__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: var(--ink-fg);
}
.be__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff9ebb;
}
.be__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.be__cv {
display: block;
width: 100%;
height: 100%;
}
.be__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.be__field {
height: 124px;
}
.be__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.be.is-still .be__badge {
opacity: 0.92 !important;
}
}// 맥놀이 진폭 엔벨로프 — 주파수가 살짝 다른(9Hz·10Hz) 두 Math.sin 파를 더하면 맥놀이가 생긴다.
// requestAnimationFrame 루프에서 합성 파형을 그리고, strokeStyle 을 바꿔 가며 포락선(envelope)
// 곡선을 따로 그려 진폭이 주기적으로 커졌다 작아지는 것을 보여준다.
var root = document.getElementById('be');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var WAVE = css.getPropertyValue('--wave').trim();
var ENV = css.getPropertyValue('--env').trim();
var RAIL = css.getPropertyValue('--rail').trim();
var F1 = 9, F2 = 10; // 두 파동의 주파수(Hz) — 차이 1Hz 가 맥놀이 주기를 만든다
var WINDOW = 1.2; // 화면에 보이는 시간창(초)
var SCROLL = 0.6; // 파형이 흘러가는 속도(초/초)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
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);
}
function draw(sec) {
var mid = H * 0.5;
var scale = H * 0.19;
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = RAIL;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, mid + 0.5); ctx.lineTo(W, mid + 0.5);
ctx.stroke();
var t0 = sec * SCROLL;
// 합성 파형 — 두 사인의 합
ctx.strokeStyle = WAVE;
ctx.lineWidth = 2;
ctx.beginPath();
for (var x = 0; x <= W; x += 2) {
var t = t0 + (x / W) * WINDOW;
var v = Math.sin(2 * Math.PI * F1 * t) + Math.sin(2 * Math.PI * F2 * t);
var y = mid - v * scale;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
// 포락선 — |cos(2π·Δf/2·t)|·2 가 순간 최대 진폭이다. 위·아래로 대칭해 감싸는 곡선을 그린다
ctx.strokeStyle = ENV;
ctx.lineWidth = 1.6;
ctx.setLineDash([5, 4]);
ctx.beginPath();
for (var xi = 0; xi <= W; xi += 2) {
var ti = t0 + (xi / W) * WINDOW;
var env = Math.abs(Math.cos(Math.PI * (F1 - F2) * ti)) * 2;
var ye = mid - env * scale;
if (xi === 0) ctx.moveTo(xi, ye); else ctx.lineTo(xi, ye);
}
ctx.stroke();
ctx.beginPath();
for (var xj = 0; xj <= W; xj += 2) {
var tj = t0 + (xj / W) * WINDOW;
var envb = Math.abs(Math.cos(Math.PI * (F1 - F2) * tj)) * 2;
var yeb = mid + envb * scale;
if (xj === 0) ctx.moveTo(xj, yeb); else ctx.lineTo(xj, yeb);
}
ctx.stroke();
ctx.setLineDash([]);
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.15); });
if (reduce) {
// reduce — 루프 없이 한 순간의 파형·엔벨로프 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.15);
} else {
raf = requestAnimationFrame(frame);
}// BeatEnvelope — vanilla 판(run/303/demo/05-wave-beat-envelope/index.html) 의 React 포트.
// 맥놀이 진폭 엔벨로프 — 주파수가 살짝 다른(9Hz·10Hz) 두 Math.sin 파를 더하면 맥놀이가 생긴다.
// requestAnimationFrame 루프에서 합성 파형을 그리고, strokeStyle 을 바꿔 가며 포락선(envelope)
// 곡선을 따로 그려 진폭이 주기적으로 커졌다 작아지는 것을 보여준다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./BeatEnvelope.module.scss";
export type BeatEnvelopeProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "주파수가 살짝 다른 두 파동이 만나 커졌다 작아졌다 합니다";
const FIELD_LABEL = "맥놀이 파형 실험판";
const BADGE_TEXT = "9·10";
const BADGE_UNIT = "Hz";
const F1 = 9, F2 = 10; // 두 파동의 주파수(Hz) — 차이 1Hz 가 맥놀이 주기를 만든다
const WINDOW = 1.2; // 화면에 보이는 시간창(초)
const SCROLL = 0.6; // 파형이 흘러가는 속도(초/초)
export default function BeatEnvelope(props: BeatEnvelopeProps) {
const duration = props.duration ?? 2;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const accentColor = props.accentColor ?? "#ff9ebb";
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": duration + "s", "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const wave = css.getPropertyValue("--wave").trim();
const env = css.getPropertyValue("--env").trim();
const rail = css.getPropertyValue("--rail").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
function fit() {
const 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);
}
function draw(sec: number) {
const mid = H * 0.5;
const scale = H * 0.19;
ctx!.clearRect(0, 0, W, H);
ctx!.strokeStyle = rail;
ctx!.lineWidth = 1;
ctx!.beginPath();
ctx!.moveTo(0, mid + 0.5); ctx!.lineTo(W, mid + 0.5);
ctx!.stroke();
const t0 = sec * SCROLL;
// 합성 파형 — 두 사인의 합
ctx!.strokeStyle = wave;
ctx!.lineWidth = 2;
ctx!.beginPath();
for (let x = 0; x <= W; x += 2) {
const t = t0 + (x / W) * WINDOW;
const v = Math.sin(2 * Math.PI * F1 * t) + Math.sin(2 * Math.PI * F2 * t);
const y = mid - v * scale;
if (x === 0) ctx!.moveTo(x, y); else ctx!.lineTo(x, y);
}
ctx!.stroke();
// 포락선 — |cos(2π·Δf/2·t)|·2 가 순간 최대 진폭이다. 위·아래로 대칭해 감싸는 곡선을 그린다
ctx!.strokeStyle = env;
ctx!.lineWidth = 1.6;
ctx!.setLineDash([5, 4]);
ctx!.beginPath();
for (let xi = 0; xi <= W; xi += 2) {
const ti = t0 + (xi / W) * WINDOW;
const envV = Math.abs(Math.cos(Math.PI * (F1 - F2) * ti)) * 2;
const ye = mid - envV * scale;
if (xi === 0) ctx!.moveTo(xi, ye); else ctx!.lineTo(xi, ye);
}
ctx!.stroke();
ctx!.beginPath();
for (let xj = 0; xj <= W; xj += 2) {
const tj = t0 + (xj / W) * WINDOW;
const envb = Math.abs(Math.cos(Math.PI * (F1 - F2) * tj)) * 2;
const yeb = mid + envb * scale;
if (xj === 0) ctx!.moveTo(xj, yeb); else ctx!.lineTo(xj, yeb);
}
ctx!.stroke();
ctx!.setLineDash([]);
}
function frame(now: number) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) draw(0.15);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 한 순간의 파형·엔벨로프 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
draw(0.15);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
root.classList.remove("is-still");
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={styles.be} style={vars}>
<p className={styles["be__hint"]}>
<svg className={styles["be__ico"]} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M24 128c16-64 40-64 56 0s40 64 56 0s40-64 56 0s40 64 56 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round"/></g></svg>
<span>{HINT}</span>
</p>
<div ref={fieldRef} className={styles["be__field"]} role="application" aria-label={FIELD_LABEL}>
<canvas ref={cvRef} className={styles["be__cv"]}></canvas>
<b className={styles["be__badge"]} aria-hidden="true"><span className={styles["be__badgeNum"]}>{BADGE_TEXT}</span><span className={styles["be__badgeUnit"]}>{BADGE_UNIT}</span></b>
</div>
</div>
</main>
);
}05 · うなり振幅エンベロープ — 計測 累積面積 42.926% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
合成波は二つのサインの和で作る
var F1 = 9, F2 = 10; // 二つの波の周波数(Hz) — 差の1Hzがうなりの周期を作る
var WINDOW = 1.2; // 画面に見える時間窓(秒)
var SCROLL = 0.6; // 波形が流れる速さ(秒/秒)
ctx.strokeStyle = WAVE;
ctx.lineWidth = 2;
ctx.beginPath();
for (var x = 0; x <= W; x += 2) {
var t = t0 + (x / W) * WINDOW;
var v = Math.sin(2 * Math.PI * F1 * t) + Math.sin(2 * Math.PI * F2 * t);
var y = mid - v * scale;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
包絡線はなぜ|cos(π·Δf·t)|・2になるのか
ctx.strokeStyle = ENV;
ctx.lineWidth = 1.6;
ctx.setLineDash([5, 4]);
ctx.beginPath();
for (var xi = 0; xi <= W; xi += 2) {
var ti = t0 + (xi / W) * WINDOW;
var env = Math.abs(Math.cos(Math.PI * (F1 - F2) * ti)) * 2;
var ye = mid - env * scale;
if (xi === 0) ctx.moveTo(xi, ye); else ctx.lineTo(xi, ye);
}
ctx.stroke();
ctx.setLineDash([]);
二つのサインの和はcos(π·Δf·t)を包絡線に持つという三角関数の恒等式のとおり1秒周期のうなりが現れ、点線の包絡線がその瞬間の最大振幅を上下から包みます。
06極座標ローズ・リサージュ
同じサイン・コサインの式を直交座標の代わりに極座標へ入れ、回転する花びら模様が出る、01番の極座標版です。

<main class="stage">
<div class="pr" id="pr">
<p class="pr__hint">
<svg class="pr__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 32c-24 32-24 64 0 96c24-32 24-64 0-96Zm0 96c-32 24-64 24-96 0c32-24 64-24 96 0Zm0 0c32-24 64-24 96 0c-32 24-64 24-96 0Zm0 0c-24 32-24 64 0 96c24-32 24-64 0-96Z" opacity=".7"/><circle cx="128" cy="128" r="14"/></g></svg>
<span>同じリサージュの式が極座標では花びらの形になります</span>
</p>
<div class="pr__field" id="field" role="application" aria-label="極座標ローズの実験盤">
<canvas class="pr__cv" id="cv"></canvas>
<b class="pr__badge" aria-hidden="true"><span class="pr__badgeNum">5:4</span><span class="pr__badgeUnit">比</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 극좌표 로즈 리사주 — 각도 θ 에 두 주파수 비율을 넣어 극좌표로 반지름을 구하는 방식.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
.pr {
--petal: #{$subject-pink};
--core: #{$subject-mint};
--badge-bg: #{rgba($subject-cream, .12)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.pr__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-cream;
}
.pr__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-pink; }
.pr__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $stage-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .16);
}
.pr__cv { display: block; width: 100%; height: 100%; }
.pr__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.pr__field { height: 124px; }
.pr__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.pr.is-still .pr__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pr {
--petal: #ff9ebb;
--core: #4cd4a6;
--badge-bg: rgba(255, 247, 230, 0.12);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.pr__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: #fff7e6;
}
.pr__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff9ebb;
}
.pr__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.16);
}
.pr__cv {
display: block;
width: 100%;
height: 100%;
}
.pr__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.pr__field {
height: 124px;
}
.pr__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.pr.is-still .pr__badge {
opacity: 0.92 !important;
}
}// 극좌표 로즈 리사주 — 01 번과 같은 리사주 수식(두 주파수의 사인 합)이지만
// 직교좌표 대신 각도 θ 에 주파수 비율을 넣어 Math.cos 로 반지름을 구하는 극좌표 방식이라
// 회전하는 꽃잎 모양으로 나온다. Math.sin 으로 다시 x·y 좌표로 바꿔 requestAnimationFrame 마다 찍는다.
var root = document.getElementById('pr');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var PETAL = css.getPropertyValue('--petal').trim();
var CORE = css.getPropertyValue('--core').trim();
var K = 5 / 4; // 꽃잎 개수를 정하는 주파수 比 n/d
var LOOP = 2; // 곡선이 회전 한 바퀴 도는 시간(초)
var TRAIL = 0.09;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
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);
}
// 로즈 곡선 위 한 점 — r = cos(K·θ) 를 반지름으로, 회전(spin)을 더해 전체를 돌린다
function petalPoint(th, spin, R) {
var r = Math.cos(K * th) * R;
var a = th + spin;
return {
x: W * 0.5 + Math.sin(a) * r,
y: H * 0.5 + Math.cos(a) * r
};
}
function draw(sec) {
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,' + TRAIL + ')';
ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = 'source-over';
var spin = (sec % LOOP) / LOOP * Math.PI * 2 * 0.5;
var R = Math.min(W, H) * 0.42;
var N = 480; // K 가 분수라 한 바퀴를 다 그리려면 표본이 많이 필요하다
ctx.strokeStyle = PETAL;
ctx.lineWidth = 1.6;
ctx.beginPath();
for (var i = 0; i <= N; i++) {
var p = petalPoint(i / N * Math.PI * 8, spin, R);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.fillStyle = CORE;
ctx.beginPath();
ctx.arc(W * 0.5, H * 0.5, 4, 0, Math.PI * 2);
ctx.fill();
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.4); });
if (reduce) {
// reduce — 루프 없이 회전 0.4의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.4);
} else {
raf = requestAnimationFrame(frame);
}// PolarRoseLissajous — vanilla 판(run/303/demo/06-wave-polar-rose-lissajous/index.html) 의 React 포트.
// 극좌표 로즈 리사주 — 01 번과 같은 리사주 수식(두 주파수의 사인 합)이지만
// 직교좌표 대신 각도 θ 에 주파수 비율을 넣어 Math.cos 로 반지름을 구하는 극좌표 방식이라
// 회전하는 꽃잎 모양으로 나온다. Math.sin 으로 다시 x·y 좌표로 바꿔 requestAnimationFrame 마다 찍는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./PolarRoseLissajous.module.scss";
export type PolarRoseLissajousProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const HINT = "같은 리사주 수식이 극좌표에서는 꽃잎 모양이 됩니다";
const FIELD_LABEL = "극좌표 로즈 실험판";
const BADGE_TEXT = "5:4";
const BADGE_UNIT = "비율";
const K = 5 / 4; // 꽃잎 개수를 정하는 주파수 비율 n/d
const LOOP = 2; // 곡선이 회전 한 바퀴 도는 시간(초)
const TRAIL = 0.09;
export default function PolarRoseLissajous(props: PolarRoseLissajousProps) {
const duration = props.duration ?? 2;
const easing = props.easing ?? "cubic-bezier(.2, .8, .2, 1)";
const accentColor = props.accentColor ?? "#ff9ebb";
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const vars: Vars = { "--duration": duration + "s", "--easing": easing, "--color": accentColor };
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const petal = css.getPropertyValue("--petal").trim();
const core = css.getPropertyValue("--core").trim();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
function fit() {
const 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);
}
// 로즈 곡선 위 한 점 — r = cos(K·θ) 를 반지름으로, 회전(spin)을 더해 전체를 돌린다
function petalPoint(th: number, spin: number, R: number) {
const r = Math.cos(K * th) * R;
const a = th + spin;
return {
x: W * 0.5 + Math.sin(a) * r,
y: H * 0.5 + Math.cos(a) * r,
};
}
function draw(sec: number) {
ctx!.globalCompositeOperation = "destination-out";
ctx!.fillStyle = "rgba(0,0,0," + TRAIL + ")";
ctx!.fillRect(0, 0, W, H);
ctx!.globalCompositeOperation = "source-over";
const spin = (sec % LOOP) / LOOP * Math.PI * 2 * 0.5;
const R = Math.min(W, H) * 0.42;
const N = 480; // K 가 분수라 한 바퀴를 다 그리려면 표본이 많이 필요하다
ctx!.strokeStyle = petal;
ctx!.lineWidth = 1.6;
ctx!.beginPath();
for (let i = 0; i <= N; i++) {
const p = petalPoint(i / N * Math.PI * 8, spin, R);
if (i === 0) ctx!.moveTo(p.x, p.y); else ctx!.lineTo(p.x, p.y);
}
ctx!.stroke();
ctx!.fillStyle = core;
ctx!.beginPath();
ctx!.arc(W * 0.5, H * 0.5, 4, 0, Math.PI * 2);
ctx!.fill();
}
function frame(now: number) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) draw(0.4);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 회전 0.4의 한 장만 그린다(빈 화면이 아니다)
root.classList.add("is-still");
draw(0.4);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
root.classList.remove("is-still");
};
}, []);
return (
<main className={styles.stage}>
<div ref={rootRef} className={styles.pr} style={vars}>
<p className={styles["pr__hint"]}>
<svg className={styles["pr__ico"]} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 32c-24 32-24 64 0 96c24-32 24-64 0-96Zm0 96c-32 24-64 24-96 0c32-24 64-24 96 0Zm0 0c32-24 64-24 96 0c-32 24-64 24-96 0Zm0 0c-24 32-24 64 0 96c24-32 24-64 0-96Z" opacity=".7"/><circle cx="128" cy="128" r="14"/></g></svg>
<span>{HINT}</span>
</p>
<div ref={fieldRef} className={styles["pr__field"]} role="application" aria-label={FIELD_LABEL}>
<canvas ref={cvRef} className={styles["pr__cv"]}></canvas>
<b className={styles["pr__badge"]} aria-hidden="true"><span className={styles["pr__badgeNum"]}>{BADGE_TEXT}</span><span className={styles["pr__badgeUnit"]}>{BADGE_UNIT}</span></b>
</div>
</div>
</main>
);
}06 · 極座標ローズ・リサージュ — 計測 累積面積 17.09% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
半径はcos(K·θ)で決まる
var K = 5 / 4; // 花びらの数を決める周波数比 n/d
function petalPoint(th, spin, R) {
var r = Math.cos(K * th) * R;
var a = th + spin;
return {
x: W * 0.5 + Math.sin(a) * r,
y: H * 0.5 + Math.cos(a) * r
};
}
描く範囲はなぜθ 0→8πも必要なのか
var N = 480; // Kが分数なので一周描くには標本が多く必要
for (var i = 0; i <= N; i++) {
var p = petalPoint(i / N * Math.PI * 8, spin, R);
if (i === 0) ctx.moveTo(p.x, p.y); else ctx.lineTo(p.x, p.y);
}
K=5/4のように分数だと曲線が閉じるまでに8π必要で、spinが全体をゆっくり回してマンダラ風のローディング画面にそのまま使える模様を描きます。
07モアレ格子干渉
縞模様二枚を7度ずらして重ね、ずっと太いモアレ縞が流れる、干渉の紙面印刷版です。

<main class="stage">
<div class="mo" id="mo">
<p class="mo__hint">
<svg class="mo__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M32 64h192v8H32zM32 96h192v8H32zM32 128h192v8H32zM32 160h192v8H32zM32 192h192v8H32z" opacity=".5"/><path d="M64 32l8 192h-8zM96 32l8 192h-8zM128 32l8 192h-8zM160 32l8 192h-8zM192 32l8 192h-8z" opacity=".5"/></g></svg>
<span>2つの角度の縞が重なって波のようなモアレを作ります</span>
</p>
<div class="mo__field" id="field" role="application" aria-label="モアレ格子の実験盤">
<canvas class="mo__cv" id="cv"></canvas>
<b class="mo__badge" aria-hidden="true"><span class="mo__badgeNum">7°</span><span class="mo__badgeUnit">角度差</span></b>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 무아레 격자 간섭 — 두 줄무늬 층을 겹쳐 픽셀별 밝기를 곱해 무아레 무늬를 만든다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-paper);
.mo {
--line-a: #{$subject-blue};
--line-b: #{$subject-lilac};
--ink-fg: #{$subject-ink};
--badge-bg: #{rgba($subject-ink, .78)};
--badge-fg: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.mo__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: var(--ink-fg);
}
.mo__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-lilac; }
.mo__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.mo__cv { display: block; width: 100%; height: 100%; }
.mo__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: var(--badge-bg);
color: var(--badge-fg); font-weight: 700; font-size: 11px;
}
@media (max-width: 340px) {
.mo__field { height: 124px; }
.mo__badge { font-size: 10px; padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.mo.is-still .mo__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);
}
.mo {
--line-a: #2f6df6;
--line-b: #b9a0ff;
--ink-fg: #17141a;
--badge-bg: rgba(23, 20, 26, 0.78);
--badge-fg: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.mo__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: var(--ink-fg);
}
.mo__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.mo__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.mo__cv {
display: block;
width: 100%;
height: 100%;
}
.mo__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-fg);
font-weight: 700;
font-size: 11px;
}
@media (max-width: 340px) {
.mo__field {
height: 124px;
}
.mo__badge {
font-size: 10px;
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.mo.is-still .mo__badge {
opacity: 0.92 !important;
}
}// 무아레 격자 간섭 — 가로 줄무늬 두 장을 서로 다른 각도·위상으로 겹쳐 getImageData 로 읽은
// 픽셀별 밝기를 곱해 무아레 줄무늬를 만든다. putImageData 로 매 프레임 위상을 Math.sin 만큼
// 밀어 움직이는 물결처럼 보이게 한다.
var root = document.getElementById('mo');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: true });
var css = getComputedStyle(root);
var A = css.getPropertyValue('--line-a').trim();
var B = css.getPropertyValue('--line-b').trim();
var ANGLE = 7 * Math.PI / 180; // 두 층의 角度差 — 작을수록 무늬 간격이 넓어진다
var PITCH = 6; // 줄무늬 한 칸(px)
var LOOP = 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var img = null, buf = null;
function parse(c) {
var m = c.match(/^#([0-9a-f]{6})$/i);
if (!m) return [47, 109, 246];
return [parseInt(m[1].slice(0, 2), 16), parseInt(m[1].slice(2, 4), 16), parseInt(m[1].slice(4, 6), 16)];
}
var CA = parse(A), CB = parse(B);
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = Math.round(r.width); H = Math.round(r.height);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
img = ctx.createImageData(W, H);
buf = img.data;
}
// 줄무늬 층 하나의 밝기(0..1) — 회전된 좌표축을 따라 사인 줄무늬를 만든다
function stripe(x, y, angle, phase) {
var rx = x * Math.cos(angle) + y * Math.sin(angle);
var v = Math.sin(rx / PITCH * Math.PI * 2 + phase) * 0.5 + 0.5;
return v;
}
function draw(sec) {
var phase = (sec % LOOP) / LOOP * Math.PI * 2; // 위상을 Math.sin 인자로 밀어 물결처럼 흐르게 한다
for (var y = 0; y < H; y++) {
for (var x = 0; x < W; x++) {
var a = stripe(x, y, 0, phase);
var b = stripe(x, y, ANGLE, -phase * 0.6);
var v = a * b; // 두 층의 밝기를 곱해 겹침(무아레)을 만든다
var i = (y * W + x) * 4;
buf[i] = Math.round(CA[0] + (CB[0] - CA[0]) * v);
buf[i + 1] = Math.round(CA[1] + (CB[1] - CA[1]) * v);
buf[i + 2] = Math.round(CA[2] + (CB[2] - CA[2]) * v);
buf[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(now) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.35); });
if (reduce) {
// reduce — 루프 없이 위상 0.35 의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.35);
} else {
raf = requestAnimationFrame(frame);
}// MoireGrating — vanilla 판(run/303/demo/07-wave-moire-grating/index.html) 의 React 포트.
// 두 줄무늬 층을 겹쳐 픽셀별 밝기를 곱해 무아레 무늬를 만든다. 움직임은 전부 캔버스 rAF 루프 안.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MoireGrating.module.scss";
export type MoireGratingProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TITLE = "무아레 격자 간섭 데모";
const HINT_TEXT = "두 각도의 줄무늬가 겹쳐 만드는 물결 같은 무아레 무늬";
const FIELD_LABEL = "무아레 격자 실험판";
const BADGE_NUM = "7°";
const BADGE_UNIT = "각도차";
export default function MoireGrating({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#b9a0ff",
}: MoireGratingProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const [still, setStill] = useState(false);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": accentColor,
};
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d", { willReadFrequently: true });
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const lineA = css.getPropertyValue("--line-a").trim();
const lineB = css.getPropertyValue("--line-b").trim();
const ANGLE = 7 * Math.PI / 180; // 두 층의 각도차 — 작을수록 무늬 간격이 넓어진다
const PITCH = 6; // 줄무늬 한 칸(px)
const LOOP = 2;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
let img: ImageData | null = null;
let buf: Uint8ClampedArray | null = null;
function parse(c: string): [number, number, number] {
const m = c.match(/^#([0-9a-f]{6})$/i);
if (!m) return [47, 109, 246];
return [
parseInt(m[1].slice(0, 2), 16),
parseInt(m[1].slice(2, 4), 16),
parseInt(m[1].slice(4, 6), 16),
];
}
const CA = parse(lineA), CB = parse(lineB);
function fit() {
const r = field!.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = Math.round(r.width); H = Math.round(r.height);
cv!.width = Math.round(W * dpr);
cv!.height = Math.round(H * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
img = ctx!.createImageData(W, H);
buf = img.data;
}
// 줄무늬 층 하나의 밝기(0..1) — 회전된 좌표축을 따라 사인 줄무늬를 만든다
function stripe(x: number, y: number, angle: number, phase: number) {
const rx = x * Math.cos(angle) + y * Math.sin(angle);
return Math.sin(rx / PITCH * Math.PI * 2 + phase) * 0.5 + 0.5;
}
function draw(sec: number) {
if (!img || !buf) return;
const b = buf;
const phase = (sec % LOOP) / LOOP * Math.PI * 2; // 위상을 Math.sin 인자로 밀어 물결처럼 흐르게 한다
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const a = stripe(x, y, 0, phase);
const c = stripe(x, y, ANGLE, -phase * 0.6);
const v = a * c; // 두 층의 밝기를 곱해 겹침(무아레)을 만든다
const i = (y * W + x) * 4;
b[i] = Math.round(CA[0] + (CB[0] - CA[0]) * v);
b[i + 1] = Math.round(CA[1] + (CB[1] - CA[1]) * v);
b[i + 2] = Math.round(CA[2] + (CB[2] - CA[2]) * v);
b[i + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function frame(now: number) {
draw(now / 1000);
raf = requestAnimationFrame(frame);
}
function onResize() {
fit();
if (reduce) draw(0.35);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 위상 0.35 의 한 장만 그린다(빈 화면이 아니다)
setStill(true);
draw(0.35);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
};
}, []);
return (
<main className={styles.stage}>
<div
className={`${styles.mo}${still ? ` ${styles["is-still"]}` : ""}`}
ref={rootRef}
style={vars}
aria-label={TITLE}
>
<p className={styles["mo__hint"]}>
<svg className={styles["mo__ico"]} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M32 64h192v8H32zM32 96h192v8H32zM32 128h192v8H32zM32 160h192v8H32zM32 192h192v8H32z" opacity=".5" />
<path d="M64 32l8 192h-8zM96 32l8 192h-8zM128 32l8 192h-8zM160 32l8 192h-8zM192 32l8 192h-8z" opacity=".5" />
</g>
</svg>
<span>{HINT_TEXT}</span>
</p>
<div
className={styles["mo__field"]}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
>
<canvas className={styles["mo__cv"]} ref={cvRef}></canvas>
<b className={styles["mo__badge"]} aria-hidden="true">
<span className={styles["mo__badgeNum"]}>{BADGE_NUM}</span>
<span className={styles["mo__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
</div>
</main>
);
}07 · モアレ格子干渉 — 計測 累積面積 60.433% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
格子一枚は回転軸サイン縞で作る
var ANGLE = 7 * Math.PI / 180; // 二層の角度差 — 小さいほど模様の間隔が広くなる
var PITCH = 6; // 縞一本分(px)
function stripe(x, y, angle, phase) {
var rx = x * Math.cos(angle) + y * Math.sin(angle);
var v = Math.sin(rx / PITCH * Math.PI * 2 + phase) * 0.5 + 0.5;
return v;
}
重なりは二つの明るさの積で決まる
var a = stripe(x, y, 0, phase);
var b = stripe(x, y, ANGLE, -phase * 0.6);
var v = a * b; // 二層の明るさを掛けて重なり(モアレ)を作る
縞の角度差が小さいほどモアレの間隔が広くなる周期の関係がそのまま現れ、位相を互いに反対方向へ押して太い縞が波のように流れます。累積面積60.43%で九つの中で一番広く動きます。
08位相差 比較パネル
位相δのスライダーを動かすと同じ二つの波が直線→楕円→円へ変わる過程を三つの区画に分けて見せる、リサージュ曲線の実験道具です。

<main class="stage">
<div class="pd" id="pd">
<p class="pd__hint">
<svg class="pd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M32 192V64a8 8 0 0 1 16 0v128a8 8 0 0 1-16 0Z" opacity=".45"/><path d="M64 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0Z" opacity=".45"/><path d="M208 192V64a8 8 0 0 1 16 0v128a8 8 0 0 1-16 0Z" opacity=".45"/><path d="M240 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0Z" opacity=".45"/><ellipse cx="128" cy="128" rx="52" ry="36" fill="none" stroke="currentColor" stroke-width="10"/></g></svg>
<span>位相差を動かすと直線が楕円になり、円になります</span>
</p>
<div class="pd__field" id="field" role="application" aria-label="位相差比較の実験盤">
<canvas class="pd__cv" id="cv"></canvas>
<b class="pd__badge" aria-hidden="true"><span class="pd__badgeNum">0.00</span><span class="pd__badgeUnit">π rad</span></b>
</div>
<p class="pd__ctrl">
<label class="pd__ctrlLabel" for="phase">位相 δ</label>
<input class="pd__slider" id="phase" type="range" min="0" max="100" value="0" step="0.5" aria-label="位相差の調整">
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 위상차 비교 패널 — 슬라이더로 위상 δ 를 바꾸면 리사주 곡선이 직선 → 타원 → 원으로 바뀐다.
// 움직임은 전부 캔버스 rAF 루프 안에 있다. @keyframes 0개.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-paper);
.pd {
--trace: #{$subject-blue};
--x-wave: #{$subject-sky};
--y-wave: #{$subject-mint};
--rail: #{rgba($subject-ink, .14)};
--ink-fg: #{$subject-ink};
--badge-bg: #{rgba($subject-ink, .78)};
--badge-fg: #{$subject-cream};
--track: #{rgba($subject-ink, .16)};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.pd__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: var(--ink-fg);
}
.pd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-sky; }
.pd__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-cream;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
}
.pd__cv { display: block; width: 100%; height: 100%; }
.pd__ctrl {
display: flex; align-items: center; gap: $sp-2;
margin: 0;
}
.pd__ctrlLabel {
flex: 0 0 auto; font-weight: 700; font-size: 12px; color: var(--ink-fg);
font-variant-numeric: tabular-nums;
}
.pd__slider {
flex: 1 1 auto; margin: 0; width: 100%;
appearance: none; -webkit-appearance: none;
height: 4px; border-radius: $r-pill; background: var(--track);
outline: none;
}
.pd__slider::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: 18px; height: 18px; border-radius: 50%;
background: $color; border: none; cursor: pointer;
}
.pd__slider::-moz-range-thumb {
width: 18px; height: 18px; border-radius: 50%;
background: $color; border: none; cursor: pointer;
}
.pd__slider:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
@media (max-width: 340px) {
.pd__field { height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.pd.is-still .pd__ctrlLabel { 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);
}
.pd {
--trace: #2f6df6;
--x-wave: #6ec8ff;
--y-wave: #4cd4a6;
--rail: rgba(23, 20, 26, 0.14);
--ink-fg: #17141a;
--badge-bg: rgba(23, 20, 26, 0.78);
--badge-fg: #fff7e6;
--track: rgba(23, 20, 26, 0.16);
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.pd__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: var(--ink-fg);
}
.pd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #6ec8ff;
}
.pd__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #fff7e6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
}
.pd__cv {
display: block;
width: 100%;
height: 100%;
}
.pd__ctrl {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.pd__ctrlLabel {
flex: 0 0 auto;
font-weight: 700;
font-size: 12px;
color: var(--ink-fg);
font-variant-numeric: tabular-nums;
}
.pd__slider {
flex: 1 1 auto;
margin: 0;
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 4px;
border-radius: 999px;
background: var(--track);
outline: none;
}
.pd__slider::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #6ec8ff;
border: none;
cursor: pointer;
}
.pd__slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: #6ec8ff;
border: none;
cursor: pointer;
}
.pd__slider:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.pd__field {
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.pd.is-still .pd__ctrlLabel {
opacity: 0.92 !important;
}
}// 위상차 비교 패널 — 같은 주파수의 두 사인파를 X·Y 에 걸되 위상차 δ 만 다르게 하면
// 리사주 도형이 δ=0 직선 → δ=π/2 원 → δ=π 반직선으로 바뀐다.
// requestAnimationFrame 마다 input[type=range] 슬라이더 값을 읽어 Math.sin 위상에 반영한다.
// 슬라이더를 만지지 않을 때는 자동 루프가 δ 를 0→2π 로 천천히 왕복한다(미리보기·측정용).
var root = document.getElementById('pd');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d');
var slider = document.getElementById('phase');
var css = getComputedStyle(root);
var TRACE = css.getPropertyValue('--trace').trim();
var XW = css.getPropertyValue('--x-wave').trim();
var YW = css.getPropertyValue('--y-wave').trim();
var RAIL = css.getPropertyValue('--rail').trim();
var LOOP = 2; // 자동 루프가 δ 를 0→2π 왕복하는 시간(초)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var manual = false; // 사람이 슬라이더를 잡고 있으면 자동 루프를 멈춘다
var numEl = document.querySelector('.pd__badgeNum');
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);
}
// 화면을 세 팔로 나눈다 — 왼쪽 X 파형·오른쪽 Y 파형·가운데 리사주 도형
function draw(delta, sec) {
ctx.clearRect(0, 0, W, H);
var mid = H * 0.5;
var cxr = W * 0.5, R = Math.min(W * 0.28, H * 0.36);
var xw0 = W * 0.04, xw1 = W * 0.30; // X 파형 영역
var yw0 = W * 0.70, yw1 = W * 0.96; // Y 파형 영역
var t = sec;
// 레일
ctx.strokeStyle = RAIL;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xw0, mid + 0.5); ctx.lineTo(xw1, mid + 0.5);
ctx.moveTo(yw0, mid + 0.5); ctx.lineTo(yw1, mid + 0.5);
ctx.moveTo(cxr - R - 8, mid + 0.5); ctx.lineTo(cxr + R + 8, mid + 0.5);
ctx.moveTo(cxr, mid - R - 8); ctx.lineTo(cxr, mid + R + 8);
ctx.stroke();
// X 파형 — sin(ωt)
ctx.strokeStyle = XW;
ctx.lineWidth = 2;
ctx.beginPath();
for (var x = 0; x <= xw1 - xw0; x += 2) {
var ph = t * Math.PI * 2 + x / (xw1 - xw0) * Math.PI * 2;
var y = mid - Math.sin(ph) * R * 0.8;
if (x === 0) ctx.moveTo(xw0 + x, y); else ctx.lineTo(xw0 + x, y);
}
ctx.stroke();
// Y 파형 — sin(ωt + δ)
ctx.strokeStyle = YW;
ctx.beginPath();
for (var x2 = 0; x2 <= yw1 - yw0; x2 += 2) {
var ph2 = t * Math.PI * 2 + x2 / (yw1 - yw0) * Math.PI * 2 + delta;
var y2 = mid - Math.sin(ph2) * R * 0.8;
if (x2 === 0) ctx.moveTo(yw0 + x2, y2); else ctx.lineTo(yw0 + x2, y2);
}
ctx.stroke();
// 리사주 도형 — X=sin(ωt), Y=sin(ωt+δ). δ 가 0 이면 대각선 직선, π/2 면 원, π 면 반대 대각선
ctx.strokeStyle = TRACE;
ctx.lineWidth = 2;
ctx.beginPath();
var N = 120;
for (var i = 0; i <= N; i++) {
var th = i / N * Math.PI * 2;
var px = cxr + Math.sin(th) * R;
var py = mid - Math.sin(th + delta) * R;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
// 진행 점 — 두 파형의 지금 값을 잇는 밝은 점
var hx = cxr + Math.sin(t * Math.PI * 2) * R;
var hy = mid - Math.sin(t * Math.PI * 2 + delta) * R;
ctx.fillStyle = TRACE;
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, Math.PI * 2); ctx.fill();
if (numEl) {
var v = (delta / Math.PI).toFixed(2);
if (numEl.textContent !== v) numEl.textContent = v;
}
}
function frame(now) {
var sec = now / 1000;
var delta;
if (manual) {
delta = slider.value / 100 * Math.PI * 2;
} else {
// 자동 왕복 — 0→2π 를 갔다가 되돌아온다(루프가 이어진다)
var u = (sec % LOOP) / LOOP;
delta = (u < 0.5 ? u * 2 : (1 - u) * 2) * Math.PI * 2;
if (slider) slider.value = String(Math.round(delta / (Math.PI * 2) * 100));
}
draw(delta, sec);
raf = requestAnimationFrame(frame);
}
if (slider) {
slider.addEventListener('input', function () { manual = true; });
slider.addEventListener('pointerdown', function () { manual = true; });
slider.addEventListener('keydown', function () { manual = true; });
}
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(Math.PI / 2, 0.2); });
if (reduce) {
// reduce — 루프 없이 δ=π/2 (원) 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(Math.PI / 2, 0.2);
} else {
raf = requestAnimationFrame(frame);
}// PhaseDiffPanel — vanilla 판(run/303/demo/08-wave-phase-diff-panel/index.html) 의 React 포트.
// 같은 주파수의 두 사인파를 X·Y 에 걸되 위상차 δ 만 다르게 하면 리사주 도형이
// δ=0 직선 → δ=π/2 원 → δ=π 반직선으로 바뀐다. 슬라이더를 만지지 않을 때는 자동 루프.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PhaseDiffPanel.module.scss";
export type PhaseDiffPanelProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// 이벤트 타입은 로컬로만 정의 (React 전역 타입 import 금지)
type RangeEvt = { target: { value: string } };
const TITLE = "위상차 비교 패널 데모";
const HINT_TEXT = "위상차를 밀면 직선이 타원이 되고 원이 됩니다";
const FIELD_LABEL = "위상차 비교 실험판";
const CTRL_LABEL = "위상 δ";
const SLIDER_LABEL = "위상차 조절";
const BADGE_UNIT = "π rad";
export default function PhaseDiffPanel({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#6ec8ff",
}: PhaseDiffPanelProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const sliderRef = useRef<HTMLInputElement>(null);
const numRef = useRef<HTMLSpanElement>(null);
const [still, setStill] = useState(false);
const [sliderValue, setSliderValue] = useState("0");
const manualRef = useRef(false);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": accentColor,
};
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
const slider = sliderRef.current;
const numEl = numRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const TRACE = css.getPropertyValue("--trace").trim();
const XW = css.getPropertyValue("--x-wave").trim();
const YW = css.getPropertyValue("--y-wave").trim();
const RAIL = css.getPropertyValue("--rail").trim();
const LOOP = 2; // 자동 루프가 δ 를 0→2π 왕복하는 시간(초)
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
const manual = manualRef; // 사람이 슬라이더를 잡고 있으면 자동 루프를 멈춘다
function fit() {
const 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);
}
// 화면을 세 팔로 나눈다 — 왼쪽 X 파형·오른쪽 Y 파형·가운데 리사주 도형
function draw(delta: number, sec: number) {
ctx.clearRect(0, 0, W, H);
const mid = H * 0.5;
const cxr = W * 0.5, R = Math.min(W * 0.28, H * 0.36);
const xw0 = W * 0.04, xw1 = W * 0.30; // X 파형 영역
const yw0 = W * 0.70, yw1 = W * 0.96; // Y 파형 영역
const t = sec;
// 레일
ctx.strokeStyle = RAIL;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(xw0, mid + 0.5); ctx.lineTo(xw1, mid + 0.5);
ctx.moveTo(yw0, mid + 0.5); ctx.lineTo(yw1, mid + 0.5);
ctx.moveTo(cxr - R - 8, mid + 0.5); ctx.lineTo(cxr + R + 8, mid + 0.5);
ctx.moveTo(cxr, mid - R - 8); ctx.lineTo(cxr, mid + R + 8);
ctx.stroke();
// X 파형 — sin(ωt)
ctx.strokeStyle = XW;
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x <= xw1 - xw0; x += 2) {
const ph = t * Math.PI * 2 + x / (xw1 - xw0) * Math.PI * 2;
const y = mid - Math.sin(ph) * R * 0.8;
if (x === 0) ctx.moveTo(xw0 + x, y); else ctx.lineTo(xw0 + x, y);
}
ctx.stroke();
// Y 파형 — sin(ωt + δ)
ctx.strokeStyle = YW;
ctx.beginPath();
for (let x2 = 0; x2 <= yw1 - yw0; x2 += 2) {
const ph2 = t * Math.PI * 2 + x2 / (yw1 - yw0) * Math.PI * 2 + delta;
const y2 = mid - Math.sin(ph2) * R * 0.8;
if (x2 === 0) ctx.moveTo(yw0 + x2, y2); else ctx.lineTo(yw0 + x2, y2);
}
ctx.stroke();
// 리사주 도형 — X=sin(ωt), Y=sin(ωt+δ). δ 가 0 이면 대각선 직선, π/2 면 원, π 면 반대 대각선
ctx.strokeStyle = TRACE;
ctx.lineWidth = 2;
ctx.beginPath();
const N = 120;
for (let i = 0; i <= N; i++) {
const th = i / N * Math.PI * 2;
const px = cxr + Math.sin(th) * R;
const py = mid - Math.sin(th + delta) * R;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
// 진행 점 — 두 파형의 지금 값을 잇는 밝은 점
const hx = cxr + Math.sin(t * Math.PI * 2) * R;
const hy = mid - Math.sin(t * Math.PI * 2 + delta) * R;
ctx.fillStyle = TRACE;
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, Math.PI * 2); ctx.fill();
if (numEl) {
const v = (delta / Math.PI).toFixed(2);
if (numEl.textContent !== v) numEl.textContent = v;
}
}
function frame(now: number) {
const sec = now / 1000;
let delta: number;
if (manual.current) {
delta = Number((sliderRef.current && sliderRef.current.value) || 0) / 100 * Math.PI * 2;
} else {
// 자동 왕복 — 0→2π 를 갔다가 되돌아온다(루프가 이어진다)
const u = (sec % LOOP) / LOOP;
delta = (u < 0.5 ? u * 2 : (1 - u) * 2) * Math.PI * 2;
if (slider) slider.value = String(Math.round(delta / (Math.PI * 2) * 100));
}
draw(delta, sec);
raf = requestAnimationFrame(frame);
}
function onInput() { manual.current = true; }
function onPointerDown() { manual.current = true; }
function onKeyDown() { manual.current = true; }
function onResize() {
fit();
if (reduce) draw(Math.PI / 2, 0.2);
}
if (slider) {
slider.addEventListener("input", onInput);
slider.addEventListener("pointerdown", onPointerDown);
slider.addEventListener("keydown", onKeyDown);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 δ=π/2 (원) 한 장만 그린다(빈 화면이 아니다)
setStill(true);
draw(Math.PI / 2, 0.2);
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
if (slider) {
slider.removeEventListener("input", onInput);
slider.removeEventListener("pointerdown", onPointerDown);
slider.removeEventListener("keydown", onKeyDown);
}
};
}, []);
return (
<main className={styles.stage}>
<div
className={`${styles.pd}${still ? ` ${styles["is-still"]}` : ""}`}
ref={rootRef}
style={vars}
aria-label={TITLE}
>
<p className={styles["pd__hint"]}>
<svg className={styles["pd__ico"]} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d="M32 192V64a8 8 0 0 1 16 0v128a8 8 0 0 1-16 0Z" opacity=".45" />
<path d="M64 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0Z" opacity=".45" />
<path d="M208 192V64a8 8 0 0 1 16 0v128a8 8 0 0 1-16 0Z" opacity=".45" />
<path d="M240 96v64a8 8 0 0 1-16 0V96a8 8 0 0 1 16 0Z" opacity=".45" />
<ellipse cx="128" cy="128" rx="52" ry="36" fill="none" stroke="currentColor" stroke-width="10" />
</g>
</svg>
<span>{HINT_TEXT}</span>
</p>
<div
className={styles["pd__field"]}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
>
<canvas className={styles["pd__cv"]} ref={cvRef}></canvas>
<b className={styles["pd__badge"]} aria-hidden="true">
<span className={styles["pd__badgeNum"]} ref={numRef}>0.00</span>
<span className={styles["pd__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
<p className={styles["pd__ctrl"]}>
<label className={styles["pd__ctrlLabel"]} htmlFor="phase">{CTRL_LABEL}</label>
<input
className={styles["pd__slider"]}
ref={sliderRef}
id="phase"
type="range"
min="0"
max="100"
value={sliderValue}
step="0.5"
aria-label={SLIDER_LABEL}
onChange={(e: RangeEvt) => { manualRef.current = true; setSliderValue(e.target.value); }}
/>
</p>
</div>
</main>
);
}08 · 位相差比較パネル — 計測 累積面積 27.153% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
X=sin(ωt)、Y=sin(ωt+δ)は何を表しているのか
var N = 120;
for (var i = 0; i <= N; i++) {
var th = i / N * Math.PI * 2;
var px = cxr + Math.sin(th) * R;
var py = mid - Math.sin(th + delta) * R;
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
スライダーを掴むとなぜ自動ループが止まるのか
if (manual) {
delta = slider.value / 100 * Math.PI * 2;
} else {
var u = (sec % LOOP) / LOOP;
delta = (u < 0.5 ? u * 2 : (1 - u) * 2) * Math.PI * 2;
if (slider) slider.value = String(Math.round(delta / (Math.PI * 2) * 100));
}
δ=0なら対角線の直線、π/2なら正円、πならもう一方の対角線になる位相差の定義をinput[type=range]一つで手を動かして確かめられ、触れた瞬間から自動往復が止まります。
| 位相差 δ | 波形 | 見え方 |
|---|---|---|
| 0 | 直線 | 対角線を往復するだけの動き |
| π/2 | 円 | 一定半径で回る正円 |
| π | 直線(逆向き) | もう一方の対角線 |
09円形膜 振動モード(マイク入力)
円形の振動板がモードを切り替えながら鳴るデモにマイクボタンを載せ、実際の音の振幅が板を動かすようにした場面です。

<main class="stage">
<div class="mb" id="mb">
<p class="mb__hint">
<svg class="mb__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="128" cy="128" r="24"/><path d="M80 128a48 48 0 0 1 96 0" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" opacity=".55"/><path d="M56 128a72 72 0 0 1 144 0" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" opacity=".35"/><path d="M32 128a96 96 0 0 1 192 0" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" opacity=".2"/><rect x="120" y="168" width="16" height="48" rx="8"/></g></svg>
<span>マイクをオンにすると振動板が音に反応 — 拒否時は合成信号で回転</span>
</p>
<div class="mb__field" id="field" role="application" aria-label="メンブレン振動板の実験盤">
<canvas class="mb__cv" id="cv"></canvas>
<b class="mb__badge" aria-hidden="true"><span class="mb__badgeNum">0.32</span><span class="mb__badgeUnit">RMS</span></b>
</div>
<p class="mb__ctrl">
<button class="mb__btn" id="micBtn" type="button">マイク入力をオン</button>
<span class="mb__state" id="state" role="status">合成信号で再生中</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 멤브레인 진동판 + 마이크 입력 — 움직임은 전부 캔버스 rAF 루프 안에 있다. CSS 는 무대·판·버튼만.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 프레임을 찍어
// 캔버스가 실제로 도는 모습이 담긴다(도구/demo.py 렌더(), 01~08 같은 선례).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
.mb {
--plate-bg: #{rgba($subject-cream, .07)};
--mem: #{$subject-pink};
--rim: #{$subject-cream};
--ring: #{$subject-mint};
--track: #{rgba($subject-cream, .22)};
--btn-bg: #{rgba($subject-cream, .12)};
--btn-fg: #{$subject-cream};
--state-fg: #{rgba($subject-cream, .72)};
display: flex; flex-direction: column; gap: $sp-1;
width: 100%; max-width: 424px;
}
.mb__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-cream;
}
.mb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-pink; }
.mb__field {
position: relative; height: 208px;
border-radius: $r-card; overflow: hidden;
background: $subject-ink;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
}
.mb__cv { display: block; width: 100%; height: 100%; }
.mb__badge {
position: absolute; top: $sp-2; right: $sp-2; margin: 0;
display: flex; align-items: baseline; gap: 4px;
padding: 4px $sp-2;
border-radius: $r-sm;
background: rgba($subject-cream, .12);
font-weight: 700; font-size: 12px; line-height: 1.2;
color: $subject-cream;
font-variant-numeric: tabular-nums;
}
.mb__badgeNum { font-size: 14px; }
.mb__badgeUnit { font-size: 10px; opacity: .72; }
.mb__ctrl {
display: flex; align-items: center; gap: $sp-2;
margin: 0;
}
.mb__btn {
flex: 0 0 auto;
margin: 0; padding: $sp-1 $sp-3;
border: none; border-radius: $r-pill;
background: var(--btn-bg);
font: inherit; font-weight: 700; font-size: 12px; line-height: 1.4;
color: var(--btn-fg);
cursor: pointer;
transition: background $dur-quick $ease-out, transform $dur-quick $ease-spring;
}
.mb__btn:hover { background: rgba($subject-cream, .2); }
.mb__btn:active { transform: scale(.96); }
.mb__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.mb__btn:disabled { opacity: .6; cursor: default; transform: none; }
.mb__state {
flex: 1 1 auto; min-width: 0;
font-size: 11px; line-height: 1.4; font-weight: 500;
color: var(--state-fg);
}
@media (max-width: 340px) {
.mb { gap: 0; }
.mb__hint { font-size: 10px; line-height: 1.25; }
.mb__field { height: 108px; }
.mb__ctrl { flex-direction: column; align-items: stretch; gap: 0; }
.mb__state { font-size: 10px; line-height: 1.3; }
}
@media (prefers-reduced-motion: reduce) {
.mb__btn { transition: none !important; }
.mb.is-still .mb__state { opacity: .9 !important; }
}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);
}
.mb {
--plate-bg: rgba(255, 247, 230, 0.07);
--mem: #ff9ebb;
--rim: #fff7e6;
--ring: #4cd4a6;
--track: rgba(255, 247, 230, 0.22);
--btn-bg: rgba(255, 247, 230, 0.12);
--btn-fg: #fff7e6;
--state-fg: rgba(255, 247, 230, 0.72);
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 424px;
}
.mb__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: #fff7e6;
}
.mb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff9ebb;
}
.mb__field {
position: relative;
height: 208px;
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
}
.mb__cv {
display: block;
width: 100%;
height: 100%;
}
.mb__badge {
position: absolute;
top: 8px;
right: 8px;
margin: 0;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.12);
font-weight: 700;
font-size: 12px;
line-height: 1.2;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.mb__badgeNum {
font-size: 14px;
}
.mb__badgeUnit {
font-size: 10px;
opacity: 0.72;
}
.mb__ctrl {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.mb__btn {
flex: 0 0 auto;
margin: 0;
padding: 4px 12px;
border: none;
border-radius: 999px;
background: var(--btn-bg);
font: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.4;
color: var(--btn-fg);
cursor: pointer;
transition: background 220ms ease-out, transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.mb__btn:hover {
background: rgba(255, 247, 230, 0.2);
}
.mb__btn:active {
transform: scale(0.96);
}
.mb__btn:focus-visible {
outline: 2px solid #ff9ebb;
outline-offset: 2px;
}
.mb__btn:disabled {
opacity: 0.6;
cursor: default;
transform: none;
}
.mb__state {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
line-height: 1.4;
font-weight: 500;
color: var(--state-fg);
}
@media (max-width: 340px) {
.mb {
gap: 0;
}
.mb__hint {
font-size: 10px;
line-height: 1.25;
}
.mb__field {
height: 108px;
}
.mb__ctrl {
flex-direction: column;
align-items: stretch;
gap: 0;
}
.mb__state {
font-size: 10px;
line-height: 1.3;
}
}
@media (prefers-reduced-motion: reduce) {
.mb__btn {
transition: none !important;
}
.mb.is-still .mb__state {
opacity: 0.9 !important;
}
}// 멤브레인 진동판 + 마이크 입력 — 원形 진동판(멤브레인)을 극좌표 격자로 나누고
// 반경 방향으로 서로 다른 モード(주파수)의 사인파를 더해 변위를 만든다. 기본은 시드 고정
// 합성 신호(미리보기·측정용 — getUserMedia 는 헤드리스 렌더에서 권한 대화상자로 막히므로),
// 마이크 버튼을 누르면 getUserMedia + AnalyserNode 로 실제 소리 진폭을 읽어 モード 진폭에 얹는다.
// 권한을 거부하거나 오디오 입력이 없으면 안전하게 합성 신호로 되돌아간다(폴백).
var root = document.getElementById('mb');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var ctx = cv.getContext('2d', { willReadFrequently: false });
var micBtn = document.getElementById('micBtn');
var stateEl = document.getElementById('state');
var numEl = document.querySelector('.mb__badgeNum');
var css = getComputedStyle(root);
var MEM = css.getPropertyValue('--mem').trim();
var RIM = css.getPropertyValue('--rim').trim();
var RING = css.getPropertyValue('--ring').trim();
var BGN = css.getPropertyValue('--plate-bg').trim();
var SEED = 7; // 합성 신호 시드 — 고정해야 24프레임 촬영이 매번 같은 무늬를 준다
var LOOP = 2; // 합성 루프 한 바퀴(초) — 캡처 창(2초)과 같아 gif 가 이어진다
var MODES = [ // (주파수 배수, 반경 방향 마디 수) — 클라드니 진동판의 낮은 모드들
{ f: 1.0, nodes: 1, amp: 1.00 },
{ f: 1.6, nodes: 2, amp: 0.55 },
{ f: 2.3, nodes: 3, amp: 0.30 }
];
var RG = 24, AG = 64; // 격자 — 반경 24칸 · 원주 64칸 (픽셀 계산은 여기서 한 번)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, dpr = 1, raf = 0;
var micOn = false, analyser = null, audioBuf = null, micStream = null, audioCtx = null;
// 시드 고정 난수 — Math.random 을 쓰면 프레임마다 무늬가 달라져 측정이 흔들린다
function seeded(i) {
var x = Math.sin(i * 127.1 + SEED * 311.7) * 43758.5453;
return x - Math.floor(x);
}
var PHASE = [];
for (var i = 0; i < MODES.length; i++) PHASE.push(seeded(i + 1) * Math.PI * 2);
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);
}
// 합성 진폭(0~1) — 마이크가 켜져 있으면 실측 RMS 로 대체된다
function synthLevel(sec) {
var u = (sec % LOOP) / LOOP * Math.PI * 2;
return 0.38 + 0.30 * Math.sin(u) * Math.sin(u * 0.5 + 0.7) + 0.10 * Math.sin(u * 3.1);
}
function micLevel() {
if (!analyser || !audioBuf) return null;
analyser.getByteTimeDomainData(audioBuf);
var sum = 0;
for (var i = 0; i < audioBuf.length; i++) {
var v = (audioBuf[i] - 128) / 128;
sum += v * v;
}
var rms = Math.sqrt(sum / audioBuf.length);
return Math.min(1, rms * 3.2); // 조용한 방 기준으로 게인을 올려 놓는다
}
// 진동판 그리기 — 반경 좌표를 미리 계산해 두고 매 프레임 격자 점의 변위만 다시 잰다
function draw(level, sec) {
ctx.clearRect(0, 0, W, H);
var cx = W * 0.5, cy = H * 0.5;
var R = Math.min(W, H) * 0.42;
// 접시 배경
ctx.beginPath();
ctx.arc(cx, cy, R + 10, 0, Math.PI * 2);
ctx.fillStyle = BGN;
ctx.fill();
// 모드별 진폭 — 합성은 시간으로, 마이크는 실측 레벨로
var t = sec;
var amps = [];
for (var m = 0; m < MODES.length; m++) {
var a = MODES[m].amp * level * Math.sin(t * Math.PI * 2 * MODES[m].f + PHASE[m]);
amps.push(a);
}
// 반경 방향 링들 — 클라드니식 마디. 바깥쪽 링일수록 변위가 커 진동판이 살아 있는 것처럼 보인다
for (var rg = RG; rg >= 2; rg -= 2) {
var rr = R * (rg / RG);
var wobble = 0;
for (m = 0; m < MODES.length; m++) {
wobble += amps[m] * Math.sin((rg / RG) * Math.PI * MODES[m].nodes);
}
ctx.beginPath();
for (var ag = 0; ag <= AG; ag++) {
var th = (ag / AG) * Math.PI * 2;
// 각도 방향 물결 — 모드가 섞이며 꽃잎 무늬가 생긴다
var petal = 0;
for (m = 0; m < MODES.length; m++) {
petal += amps[m] * Math.sin(MODES[m].nodes * th + PHASE[m]);
}
var rad = rr + wobble * 10 + petal * 7;
var x = cx + Math.cos(th) * rad;
var y = cy + Math.sin(th) * rad;
if (ag === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
var depth = rg / RG; // 안쪽 링은 옅게 — 입체감
ctx.strokeStyle = depth > 0.86 ? RIM : MEM;
ctx.globalAlpha = 0.22 + depth * 0.78;
ctx.lineWidth = rg > RG - 3 ? 2.5 : 1.25;
ctx.stroke();
}
ctx.globalAlpha = 1;
// 중앙 허브 — 진폭에 따라 살짝 커진다(자기 중심에서)
ctx.beginPath();
ctx.arc(cx, cy, 7 + level * 5, 0, Math.PI * 2);
ctx.fillStyle = RING;
ctx.fill();
}
var t0 = 0;
function frame(ts) {
if (!t0) t0 = ts;
var sec = (ts - t0) / 1000;
var lv = micOn ? (micLevel() || 0.3) : synthLevel(sec);
if (numEl) numEl.textContent = lv.toFixed(2);
draw(lv, sec);
raf = requestAnimationFrame(frame);
}
// 마이크 — getUserMedia 로 실제 소리를 얻는다. 거부·오류·미지원 전부 합성 폴백.
function enableMic() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
stateEl.textContent = 'このブラウザはマイク入力に対応していないため合成信号で再生します';
return;
}
stateEl.textContent = 'マイクの許可を待っています';
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
micStream = stream;
var AC = window.AudioContext || window.webkitAudioContext;
audioCtx = new AC();
var src = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
audioBuf = new Uint8Array(analyser.fftSize);
src.connect(analyser); // 분석만 — 스피커로 재생하지 않는다(하울링 방지)
micOn = true;
micBtn.disabled = true;
micBtn.textContent = 'マイクオン';
stateEl.textContent = 'マイク入力が反応中 — 音を出してみてください';
}).catch(function () {
micOn = false;
stateEl.textContent = '許可が拒否されました — 合成信号で再生します';
});
}
micBtn.addEventListener('click', enableMic);
fit();
window.addEventListener('resize', function () { fit(); if (reduce) draw(0.5, 0.3); });
if (reduce) {
// reduce — 루프 없이 level 0.5 의 한 장만 그린다(빈 화면이 아니다)
root.classList.add('is-still');
draw(0.5, 0.3);
if (numEl) numEl.textContent = '0.50';
} else {
raf = requestAnimationFrame(frame);
}// MembraneMicInput — vanilla 판(run/303/demo/09-wave-membrane-mic-input/index.html) 의 React 포트.
// 원형 진동판(멤브레인)을 극좌표 격자로 나누고 반경 방향으로 서로 다른 모드의 사인파를 더해
// 변위를 만든다. 기본은 시드 고정 합성 신호, 마이크 버튼을 누르면 getUserMedia + AnalyserNode 로
// 실제 소리 진폭을 읽어 모드 진폭에 얹는다. 권한 거부·오류·미지원은 합성 폴백.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MembraneMicInput.module.scss";
export type MembraneMicInputProps = {
duration?: number;
easing?: string;
accentColor?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TITLE = "멤브레인 진동판 마이크 입력 데모";
const HINT_TEXT = "마이크를 켜면 진동판이 소리에 반응합니다 — 거부하면 합성 신호로 도는 원판";
const FIELD_LABEL = "멤브레인 진동판 실험판";
const BADGE_UNIT = "RMS";
const MIC_BTN_LABEL = "마이크 입력 켜기";
const MIC_ON_LABEL = "마이크 켜짐";
const STATE_PLAYING = "합성 신호로 재생 중";
const STATE_WAITING = "마이크 권한을 기다리는 중";
const STATE_LIVE = "마이크 입력 반응 중 — 소리를 내 보세요";
const STATE_UNSUPPORTED = "이 브라우저는 마이크 입력을 지원하지 않아 합성 신호로 재생합니다";
const STATE_DENIED = "권한 거부 — 합성 신호로 재생합니다";
export default function MembraneMicInput({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
accentColor = "#ff9ebb",
}: MembraneMicInputProps) {
const rootRef = useRef<HTMLDivElement>(null);
const fieldRef = useRef<HTMLDivElement>(null);
const cvRef = useRef<HTMLCanvasElement>(null);
const numRef = useRef<HTMLSpanElement>(null);
// useEffect 안의 enableMic 를 버튼 onClick 으로 끌어내는 다리
const enableMicRef = useRef<() => void>(function () {});
const [still, setStill] = useState(false);
const [micOn, setMicOn] = useState(false);
const [btnLabel, setBtnLabel] = useState(MIC_BTN_LABEL);
const [stateText, setStateText] = useState(STATE_PLAYING);
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": accentColor,
};
useEffect(() => {
const root0 = rootRef.current;
const field0 = fieldRef.current;
const cv0 = cvRef.current;
const numEl = numRef.current;
if (!root0 || !field0 || !cv0) return;
const ctx0 = cv0.getContext("2d");
if (!ctx0) return;
// 중첩 함수(클로저) 안에서는 가드로 좁힌 널 타입이 전달되지 않는다 — 확인된 값으로 다시 상수를 묶는다
const root: HTMLDivElement = root0;
const field: HTMLDivElement = field0;
const cv: HTMLCanvasElement = cv0;
const ctx: CanvasRenderingContext2D = ctx0;
const css = getComputedStyle(root);
const MEM = css.getPropertyValue("--mem").trim();
const RIM = css.getPropertyValue("--rim").trim();
const RING = css.getPropertyValue("--ring").trim();
const BGN = css.getPropertyValue("--plate-bg").trim();
const SEED = 7; // 합성 신호 시드 — 고정해야 프레임 촬영이 매번 같은 무늬를 준다
const LOOP = 2; // 합성 루프 한 바퀴(초) — 캡처 창(2초)과 같아 gif 가 이어진다
const MODES = [ // (주파수 배수, 반경 방향 마디 수) — 클라드니 진동판의 낮은 모드들
{ f: 1.0, nodes: 1, amp: 1.00 },
{ f: 1.6, nodes: 2, amp: 0.55 },
{ f: 2.3, nodes: 3, amp: 0.30 },
];
const RG = 24, AG = 64; // 격자 — 반경 24칸 · 원주 64칸 (픽셀 계산은 여기서 한 번)
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let W = 0, H = 0, dpr = 1, raf = 0;
let micActive = false;
let analyser: AnalyserNode | null = null;
let audioBuf: Uint8Array | null = null;
let micStream: MediaStream | null = null;
let audioCtx: AudioContext | null = null;
// 시드 고정 난수 — Math.random 을 쓰면 프레임마다 무늬가 달라져 측정이 흔들린다
function seeded(i: number) {
const x = Math.sin(i * 127.1 + SEED * 311.7) * 43758.5453;
return x - Math.floor(x);
}
const PHASE: number[] = [];
for (let i = 0; i < MODES.length; i++) PHASE.push(seeded(i + 1) * Math.PI * 2);
function fit() {
const 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);
}
// 합성 진폭(0~1) — 마이크가 켜져 있으면 실측 RMS 로 대체된다
function synthLevel(sec: number) {
const u = (sec % LOOP) / LOOP * Math.PI * 2;
return 0.38 + 0.30 * Math.sin(u) * Math.sin(u * 0.5 + 0.7) + 0.10 * Math.sin(u * 3.1);
}
function micLevel() {
if (!analyser || !audioBuf) return null;
// Uint8Array<ArrayBufferLike> 는 인수로 못 넘긴다(TS5.7 제네릭) — ArrayBuffer 로 좁혀 넘긴다
analyser.getByteTimeDomainData(audioBuf as Uint8Array<ArrayBuffer>);
let sum = 0;
for (let i = 0; i < audioBuf.length; i++) {
const v = (audioBuf[i] - 128) / 128;
sum += v * v;
}
const rms = Math.sqrt(sum / audioBuf.length);
return Math.min(1, rms * 3.2); // 조용한 방 기준으로 게인을 올려 놓는다
}
// 진동판 그리기 — 반경 좌표를 미리 계산해 두고 매 프레임 격자 점의 변위만 다시 잰다
function draw(level: number, sec: number) {
ctx.clearRect(0, 0, W, H);
const cx = W * 0.5, cy = H * 0.5;
const R = Math.min(W, H) * 0.42;
// 접시 배경
ctx.beginPath();
ctx.arc(cx, cy, R + 10, 0, Math.PI * 2);
ctx.fillStyle = BGN;
ctx.fill();
// 모드별 진폭 — 합성은 시간으로, 마이크는 실측 레벨로
const t = sec;
const amps: number[] = [];
for (let m = 0; m < MODES.length; m++) {
const a = MODES[m].amp * level * Math.sin(t * Math.PI * 2 * MODES[m].f + PHASE[m]);
amps.push(a);
}
// 반경 방향 링들 — 클라드니식 마디. 바깥쪽 링일수록 변위가 커 진동판이 살아 있는 것처럼 보인다
for (let rg = RG; rg >= 2; rg -= 2) {
const rr = R * (rg / RG);
let wobble = 0;
for (let m = 0; m < MODES.length; m++) {
wobble += amps[m] * Math.sin((rg / RG) * Math.PI * MODES[m].nodes);
}
ctx.beginPath();
for (let ag = 0; ag <= AG; ag++) {
const th = (ag / AG) * Math.PI * 2;
// 각도 방향 물결 — 모드가 섞이며 꽃잎 무늬가 생긴다
let petal = 0;
for (let m = 0; m < MODES.length; m++) {
petal += amps[m] * Math.sin(MODES[m].nodes * th + PHASE[m]);
}
const rad = rr + wobble * 10 + petal * 7;
const x = cx + Math.cos(th) * rad;
const y = cy + Math.sin(th) * rad;
if (ag === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.closePath();
const depth = rg / RG; // 안쪽 링은 옅게 — 입체감
ctx.strokeStyle = depth > 0.86 ? RIM : MEM;
ctx.globalAlpha = 0.22 + depth * 0.78;
ctx.lineWidth = rg > RG - 3 ? 2.5 : 1.25;
ctx.stroke();
}
ctx.globalAlpha = 1;
// 중앙 허브 — 진폭에 따라 살짝 커진다(자기 중심에서)
ctx.beginPath();
ctx.arc(cx, cy, 7 + level * 5, 0, Math.PI * 2);
ctx.fillStyle = RING;
ctx.fill();
}
let t0 = 0;
function frame(ts: number) {
if (!t0) t0 = ts;
const sec = (ts - t0) / 1000;
const lv = micActive ? (micLevel() || 0.3) : synthLevel(sec);
if (numEl) numEl.textContent = lv.toFixed(2);
draw(lv, sec);
raf = requestAnimationFrame(frame);
}
// 마이크 — getUserMedia 로 실제 소리를 얻는다. 거부·오류·미지원 전부 합성 폴백.
function enableMic() {
const nav = (navigator as unknown as {
mediaDevices?: { getUserMedia?: (c: { audio: boolean }) => Promise<MediaStream> };
});
if (!nav.mediaDevices || !nav.mediaDevices.getUserMedia) {
setStateText(STATE_UNSUPPORTED);
return;
}
setStateText(STATE_WAITING);
nav.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
micStream = stream;
const AC = (window as unknown as {
AudioContext?: new () => AudioContext;
webkitAudioContext?: new () => AudioContext;
});
const Ctor = AC.AudioContext || AC.webkitAudioContext;
if (!Ctor) return;
audioCtx = new Ctor();
const src = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
audioBuf = new Uint8Array(analyser.fftSize);
src.connect(analyser); // 분석만 — 스피커로 재생하지 않는다(하울링 방지)
micActive = true;
setMicOn(true);
setBtnLabel(MIC_ON_LABEL);
setStateText(STATE_LIVE);
}).catch(function () {
micActive = false;
setStateText(STATE_DENIED);
});
}
enableMicRef.current = enableMic;
function onResize() {
fit();
if (reduce) draw(0.5, 0.3);
}
fit();
window.addEventListener("resize", onResize);
if (reduce) {
// reduce — 루프 없이 level 0.5 의 한 장만 그린다(빈 화면이 아니다)
setStill(true);
draw(0.5, 0.3);
if (numEl) numEl.textContent = "0.50";
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", onResize);
if (micStream) {
micStream.getTracks().forEach(function (tr) { tr.stop(); });
}
if (audioCtx) audioCtx.close().catch(function () { /* 종류 무관 — 이미 닫혔을 수 있다 */ });
};
}, []);
return (
<main className={styles.stage}>
<div
className={`${styles.mb}${still ? ` ${styles["is-still"]}` : ""}`}
ref={rootRef}
style={vars}
aria-label={TITLE}
>
<p className={styles["mb__hint"]}>
<svg className={styles["mb__ico"]} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<circle cx="128" cy="128" r="24" />
<path d="M80 128a48 48 0 0 1 96 0" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" opacity=".55" />
<path d="M56 128a72 72 0 0 1 144 0" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" opacity=".35" />
<path d="M32 128a96 96 0 0 1 192 0" fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" opacity=".2" />
<rect x="120" y="168" width="16" height="48" rx="8" />
</g>
</svg>
<span>{HINT_TEXT}</span>
</p>
<div
className={styles["mb__field"]}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
>
<canvas className={styles["mb__cv"]} ref={cvRef}></canvas>
<b className={styles["mb__badge"]} aria-hidden="true">
<span className={styles["mb__badgeNum"]} ref={numRef}>0.32</span>
<span className={styles["mb__badgeUnit"]}>{BADGE_UNIT}</span>
</b>
</div>
<p className={styles["mb__ctrl"]}>
<button
className={styles["mb__btn"]}
type="button"
disabled={micOn}
onClick={function () { (enableMicRef.current || (function () {}))(); }}
>
{btnLabel}
</button>
<span className={styles["mb__state"]} role="status">{stateText}</span>
</p>
</div>
</main>
);
}09 · 円形膜の振動モード(マイク入力) — 計測 累積面積 18.58% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
合成信号はなぜシード固定乱数で始めるのか
var SEED = 7; // 合成信号のシード — 固定しないと24フレーム撮影のたびに模様が変わる
function seeded(i) {
var x = Math.sin(i * 127.1 + SEED * 311.7) * 43758.5453;
return x - Math.floor(x);
}
function synthLevel(sec) {
var u = (sec % LOOP) / LOOP * Math.PI * 2;
return 0.38 + 0.30 * Math.sin(u) * Math.sin(u * 0.5 + 0.7) + 0.10 * Math.sin(u * 3.1);
}
マイクはgetUserMedia拒否時にどう合成へ戻るのか
navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
var src = audioCtx.createMediaStreamSource(stream);
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
audioBuf = new Uint8Array(analyser.fftSize);
src.connect(analyser); // 解析のみ — スピーカーには出力しない(ハウリング防止)
micOn = true;
}).catch(function () {
micOn = false;
stateEl.textContent = '許可が拒否されました — 合成信号で再生します';
});
MDNのgetUserMedia文書でストリームを受け取りAnalyserNodeのRMSを読み、許可を拒否すると安全にシード固定の合成信号へ戻ります。
どこで壊れるか — 落とし穴
レンダラーはなぜアニメーション0個でフレームを撮れないのか
この九つはCSSの@keyframesを一つも使っていません。デモ撮影の道具はCSSアニメーションの時計を直接動かしてフレームを取り出しますが、keyframesが0個だとその時計がつかむものがなく、実時間で動くキャンバスを待つ必要があります。そこで動き全部をrequestAnimationFrameのループの中に置き、23/23フレームを全部撮影しました。CSSアニメーションなしでキャンバスだけを回す場面を検査ツールに通すときは、この構造を必ず守る必要があります。
Math.randomを使うとなぜ測定が毎回変わるのか
04番の砂粒の散らばりにMath.randomをそのまま使うと、フレームごとに模様が変わって測る値が揺れます。モードの進行は時間tだけに結び付け、砂粒の散らばりのように本来ランダムであるべき部分にだけMath.randomを残しました。
getUserMediaはなぜヘッドレスで権限ダイアログに阻まれるのか
09番を自動撮影すると、マイクの権限ダイアログは表示すらされずそのまま拒否されます。そこで既定を合成信号にし、ボタンを押したときだけgetUserMediaを試す構造にしました。プレビューと測定は合成信号のまま進み、実際のページでだけ音が乗ります。上の落とし穴をすべて避けた九つのHTML・CSS・JS原本は記事下の暗号化zipに入っていて、開く暗号はadp2w7ybです。
アクセシビリティ
prefers-reduced-motionが有効な環境では、九つとも通常のループを回さず代表となる一枚だけを残します。01は位相0.3の曲線と残像、02は位相0.25の干渉模様、03は第一ハーモニクスの最大振幅、04は(2,3)モードの節線、05はある瞬間の波形と包絡線、06は回転0.4の花びら、07は位相0.35の格子、08はδ=π/2の円、09はレベル0.5の振動板一枚です。どの項目も空のキャンバスでは終わりません。08のスライダーと09のマイクボタンはキーボードで到達でき、MDNのprefers-reduced-motion文書の推奨に沿っています。
FAQ
リサージュ曲線とは何か、最初はどこから見ればいいのか
リサージュ曲線 書き方で検索して辿り着いた方も、まずは08番の位相差スライダーから見てください。δの値を動かすと直線が楕円になり、さらに円になる過程を手で確かめられると、01番の3:2曲線がなぜあの形で動いているのかがすぐに分かります。
音波干渉のデモを授業教材として使ってもいいですか
02番が物理教科書にある二重点波源の干渉模様と同じ式で描かれているので、画面に映すのに一番向いています。波長WAVEの値を一つ変えるだけで模様の間隔が変わるので、実験変数としても見せられます。
定在波と進行波の違いをコードで確認できますか
03番のsin(nπs)・cos(ωt)の積構造がその定義そのものです。節点が動かないことを画面で確かめ、02番のようなsin(k·d−t)形の進行波と比べると、時間項の位置がどう違うかが目で分かります。









