フーリエ変換 アニメーション 9種 — 円の連鎖で星とハートを描き直す
フーリエ変換 アニメーションとは、絵を描き直す作業ではなく、絵に隠れていた周波数を円の連鎖として巻き戻す計算です。ライブラリ・WebGLなしで、描きたい線の点を複素平面上に置き、サイン・コサインの積分を一度走らせると、円の上に円が載って回るエピサイクルが生まれます。
デモ
01 fourier-epicycle-chain-basic
02 fourier-square-wave-synth
03 fourier-star-draw-path
04 fourier-heart-draw-path
05 fourier-draw-your-line
06 fourier-spectrum-magnitude
07 fourier-phase-circle-morph
08 fourier-harmonic-slider-lab
09 fourier-logo-trace-scene
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 基本のエピサイクル連鎖 — 円の上に円が載って周回し、点を打つ構造
- 02 矩形波の合成 — 奇数倍音の円いくつかで角ある波形を作る
- 03 星を描く — 係数サイズ順でペン先が一筆で星を描く
- 04 ハートを描く — 閉曲線の係数でハートを蘇らせる
- 05 自分の線を描く — マウスで引いたら円がその場で蘇らせる
- 06 スペクトラムバー — 係数の大きさが示す『どの円が働いているか』
- 07 位相モーフ — 係数はそのまま、位相だけ回すと模様が変身する
- 08 倍音スライダー — 円の数を減らすと絵がどう滲むか
- 09 ロゴトレースシーン — 暗い舞台での一筆ロゴ公開
手入力の係数から離散フーリエ変換まで、九つの段階
並び順の基準は見栄えではなく係数がどこから来て、どこへ流れるかです。01は係数を手で定義する再合成の最小単位で、02が奇数倍音のサイン級数という古典的な合成を円の縦スタックで示し、03で初めて描いた線の点に離散フーリエ変換をかけます。04はその係数の上位だけを使う圧縮の予告編、05は読者の線そのものを係数に変え、06は係数の大きさをバーで見せて「絵の指紋」とも言えるスペクトラムを示します。07は大きさを凍結して位相だけを回す分離実験、08は円の数を1から64まで動かして「どこまで減らしても形が残るか」を確かめ、09が暗い舞台の演出で締めくくります。9種すべてで24フレーム中23フレームが動き続けました。規則の反復だけで形が決まる点では、群れの個体がまわりの位置だけで操舵するボイドのシミュレーションや、匂いを追う点の集まりが自ら最短経路を育てる粘菌のシミュレーションと同じ種類の面白さです。
9種の計算は1本の道ではない
誤解しやすい点を先に言っておくと、9種全部が同じ計算を回しているわけではありません。03・04・05・07・08・09は経路の点に離散フーリエ変換を走らせる本流で、01は係数を手で定義しただけの最小構成、02はサイン級数を直接足す合成、06は2つの絵の係数を補間するという、それぞれ別の入り口です。どこで係数が生まれるかを意識すると、9つの盤の違いが一目で読めるようになります。
項目別コード
項目ごとに、挙動を左右する定数の名前をそのまま示し、動いているデモの関数を丸ごと引用します。切り詰めた断片ではなく動いているコードそのものなので、この画面から自分のページへ貼って動かせます。コメントだけこの記事の言葉に置き換えてあり、変数名と関数名はzipの中身と同じ英語のままです。
01基本のエピサイクル連鎖 — 円の上に円が載って周回し、点を打つ構造
周波数2・3・5・7・9の5つの係数を半径0.34から0.05まで直接書き下ろし、外側の円の端から内側の円の中心へ順に繋ぐと、末端の点が花びらのような対称模様を描いて周回します。DFTもサンプリングもまだ登場しない、f(t) = Σ r_k·e^(i(k·t + φ_k)) をそのまま画面に置いた構造です。背景の極座標グリッドは回るスポーク16本と息づく同心円4つを毎フレーム描き直し、24フレーム中23フレームが動いて累積面積36.176%、強度平均は06と並んで9種で最も明るい33.1に達します。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>あらかじめ定義した複素係数を、半径の小さくなる円の連鎖へ順に繋ぐと、末端の点が花びらのような対称模様を描いて周回します。これがフーリエ再合成の最小単位です</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="基本エピサイクル連鎖 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 5項・係数を直接定義</span></span>
<span class="fe__meta fe__meta--dim">canvas · complex chain · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 기본 에피사이클 체인(01). 푸리에 재합성의 최소 단위 —
// 계수열 coef[k] = (반지름, 시작 위상) 을 직접 정의하고 주파수 k 배로 도는 원들을
// 앞 원의 끝점을 다음 원의 중심으로 삼아 이어 붙인다. 맨 끝점(펜)의 자리가
// 원운동의 합 f(t) = Σ r_k · e^(i(k·t + φ_k)) 이다. 계수를 잘 고르면
// 꽃잎 무늬가 되고, 이 구조 그대로 경로 점의 DFT 계수로 바꾸면(03·04) 그림이 그려진다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 밑바탕 극격자(회전 스포크·동심원)가 매 프레임 다시 그려진다 — 푸리에의 본향인 극좌표 무늬가
// 천천히 돌며 화면 전면이 살아 움직인다(누적면적 ↑).
// · 궤적은 투명 누적 캔버스에 쌓고 혜성 꼬리처럼 서서히 감쇠한다(절대 clear 하지 않는다).
// · 펜 끝에 크기가 맥동하는 점을 얹어 순간 강도를 유지한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var SPEED = 1.0; // 회전 속도(배) — 변형 "시간 속도"가 이 값을 1→3으로 바꾼다
var TERMS = 5; // 체인에 얹는 계수(원) 개수
// ────────────────────────────────────────────────────
// 계수 5개 — 주파수 2·3·5·7·9, 위상을 어긋나게 잡아 꽃잎 대칭이 나오게 한다
var COEF = [
{ k: 2, r: 0.34, p: 0.0 },
{ k: 3, r: 0.18, p: 1.1 },
{ k: 5, r: 0.12, p: 2.4 },
{ k: 7, r: 0.07, p: 0.6 },
{ k: 9, r: 0.05, p: 3.6 }
];
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var chainCv, chainCtx; // 누적 궤적용 투명 오프스크린 캔버스
var ROT = 0;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
chainCv = document.createElement('canvas');
chainCv.width = W * dpr; chainCv.height = H * dpr;
chainCtx = chainCv.getContext('2d');
chainCtx.scale(dpr, dpr);
}
// 계수 체인을 t 각도로 돌려 펜 위치까지의 중심·반지름 배열을 계산한다
function computeChain(t) {
var cx = W * 0.5, cy = H * 0.5;
var base = Math.min(W, H) * 0.48;
var pts = [{ x: cx, y: cy }];
for (var i = 0; i < TERMS && i < COEF.length; i++) {
var c = COEF[i];
var a = c.k * t + c.p;
var px = pts[i].x + Math.cos(a) * c.r * base;
var py = pts[i].y + Math.sin(a) * c.r * base;
pts.push({ x: px, y: py });
}
return pts;
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// 밑바탕 극격자 — 매 프레임 계산해 다시 그린다(절대 누적 안 함). 천천히 도는 스포크 16개 + 동심원
function drawGrid(t) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var cx = W * 0.5, cy = H * 0.5;
var maxR = Math.sqrt(cx * cx + cy * cy);
var spin = t * 0.09;
// 스포크 16 — 안쪽은 진하고 바깥은 옅게(그라디언트 아님: 두 구간 알파)
for (var i = 0; i < 16; i++) {
var a = spin + (i / 16) * Math.PI * 2;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.075 : 0.038);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 14, cy + Math.sin(a) * 14);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
// 동심원 4 — 반지름이 살짝 숨쉰다
for (var k = 1; k <= 4; k++) {
var rr = (Math.min(W, H) * 0.14) * k * (1 + 0.012 * Math.sin(t * 1.3 + k));
ctx.strokeStyle = rgba(C_GLOW, 0.05);
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
ctx.stroke();
}
}
function drawFrame(t, penPulse) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawGrid(t);
ctx.drawImage(chainCv, 0, 0, W, H);
var pts = computeChain(t);
var base = Math.min(W, H) * 0.48;
// 원 체인 — 링은 옅게, 링크는 주색
ctx.lineWidth = 1;
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].r * base;
ctx.strokeStyle = rgba(C_GLOW, 0.16 + 0.05 * i);
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = rgba(C_GLOW, 0.55);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
ctx.fillStyle = rgba(C_GLOW, 0.8);
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
// 펜 — 맥동하는 절정 점
var pen = pts[pts.length - 1];
var pr = 2.6 + penPulse * 2.4;
ctx.fillStyle = rgba(C_HOT, 0.28);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, Math.PI * 2);
ctx.fill();
return pen;
}
function stampPen(pen, prev) {
// 궤적 누적 — 펜이 지나간 자리를 주색으로 쌓는다(두꺼운 선 + 옅은 확장)
chainCtx.strokeStyle = rgba(C_GLOW, 0.85);
chainCtx.lineWidth = 1.8;
chainCtx.lineCap = 'round';
chainCtx.beginPath();
chainCtx.moveTo(prev.x, prev.y);
chainCtx.lineTo(pen.x, pen.y);
chainCtx.stroke();
chainCtx.strokeStyle = rgba(C_GLOW, 0.2);
chainCtx.lineWidth = 4.4;
chainCtx.stroke();
}
function decayTrail() {
// 혜성 감쇠 — 목적지 알파를 1 로 유지하며 목적지 외 픽셀을 천천히 지운다
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = 'destination-out';
chainCtx.fillStyle = 'rgba(0,0,0,0.028)';
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = 'source-over';
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prevPen = null;
function frame(now) {
if (t0 < 0) { t0 = now; prevPen = null; }
var t = ((now - t0) / 1000) * SPEED;
var pulse = 0.5 + 0.5 * Math.sin(t * 9.0);
decayTrail();
var pen = drawFrame(t, pulse);
if (prevPen) stampPen(pen, prevPen);
prevPen = pen;
if (nOut) nOut.textContent = String(TERMS);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 궤적을 미리 한 바퀴 쌓은 뒤 마지막 장면을 고정한다
var prev = computeChain(0)[TERMS];
for (var i = 1; i <= 240; i++) {
var tt = (i / 240) * Math.PI * 2;
var pen = computeChain(tt)[TERMS];
stampPen(pen, prev);
prev = pen;
}
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawFrame(2.1, 0.5);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// EpicycleChainBasic — vanilla 판(run/321/demo/01-fourier-epicycle-chain-basic/index.html) 의 React 포트.
// 기본 에피사이클 체인. 푸리에 재합성의 최소 단위 — 계수열 coef[k] = (반지름, 시작 위상) 을
// 직접 정의하고 주파수 k 배로 도는 원들을 앞 원의 끝점을 다음 원의 중심으로 삼아 이어 붙인다.
// 맨 끝점(펜)의 자리가 원운동의 합 f(t) = Σ r_k · e^(i(k·t + φ_k)) 이다.
// 화면이 사는 장치 — 극격자(회전 스포크·동심원)가 매 프레임 다시 그려지고,
// 궤적은 투명 누적 캔버스에 쌓여 혜성 꼬리처럼 감쇠한다(절대 clear 하지 않는다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./EpicycleChainBasic.module.scss";
export type EpicycleChainBasicProps = {
duration?: string;
easing?: string;
color?: string;
speed?: number; // 회전 속도(배) — 변형 "시간 속도"가 이 값을 1→3 으로 바꾼다
terms?: number; // 체인에 얹는 계수(원) 개수
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { k: number; r: number; p: number };
const FIELD_LABEL = "기본 에피사이클 체인 실험판";
// 계수 5개 — 주파수 2·3·5·7·9, 위상을 어긋나게 잡아 꽃잎 대칭이 나오게 한다
const COEF: Coef[] = [
{ k: 2, r: 0.34, p: 0.0 },
{ k: 3, r: 0.18, p: 1.1 },
{ k: 5, r: 0.12, p: 2.4 },
{ k: 7, r: 0.07, p: 0.6 },
{ k: 9, r: 0.05, p: 3.6 }
];
export default function EpicycleChainBasic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
speed = 1.0,
terms = 5
}: EpicycleChainBasicProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const chainCvRef = useRef<HTMLCanvasElement | null>(null);
const chainCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const speedRef = useRef(speed) as Box<number>;
const termsRef = useRef(terms) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const chainCv = document.createElement("canvas");
chainCv.width = W * dpr; chainCv.height = H * dpr;
const chainCtx = chainCv.getContext("2d");
if (chainCtx) chainCtx.scale(dpr, dpr);
chainCvRef.current = chainCv;
chainCtxRef.current = chainCtx;
}
// 계수 체인을 t 각도로 돌려 펜 위치까지의 중심·반지름 배열을 계산한다
function computeChain(t: number): Pt[] {
const W = wRef.current, H = hRef.current;
const cx = W * 0.5, cy = H * 0.5;
const base = Math.min(W, H) * 0.48;
const pts: Pt[] = [{ x: cx, y: cy }];
const T = termsRef.current;
for (let i = 0; i < T && i < COEF.length; i++) {
const c = COEF[i];
const a = c.k * t + c.p;
pts.push({ x: pts[i].x + Math.cos(a) * c.r * base, y: pts[i].y + Math.sin(a) * c.r * base });
}
return pts;
}
// 밑바탕 극격자 — 매 프레임 계산해 다시 그린다(절대 누적 안 함). 천천히 도는 스포크 16개 + 동심원
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.5, cy = H * 0.5;
const maxR = Math.sqrt(cx * cx + cy * cy);
const spin = t * 0.09;
for (let i = 0; i < 16; i++) {
const a = spin + (i / 16) * Math.PI * 2;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.075 : 0.038);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 14, cy + Math.sin(a) * 14);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
for (let k = 1; k <= 4; k++) {
const rr = (Math.min(W, H) * 0.14) * k * (1 + 0.012 * Math.sin(t * 1.3 + k));
ctx.strokeStyle = rgba(col.glow, 0.05);
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
ctx.stroke();
}
}
function drawFrame(t: number, penPulse: number): Pt {
const ctx = ctxRef.current;
const chainCv = chainCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const pts: Pt[] = [];
if (!ctx || !chainCv || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawGrid(ctx, t);
ctx.drawImage(chainCv, 0, 0, W, H);
const chain = computeChain(t);
const base = Math.min(W, H) * 0.48;
// 원 체인 — 링은 옅게, 링크는 주색
ctx.lineWidth = 1;
for (let i = 0; i < chain.length - 1; i++) {
const r = COEF[i].r * base;
ctx.strokeStyle = rgba(col.glow, 0.16 + 0.05 * i);
ctx.beginPath();
ctx.arc(chain[i].x, chain[i].y, r, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = rgba(col.glow, 0.55);
ctx.beginPath();
ctx.moveTo(chain[i].x, chain[i].y);
ctx.lineTo(chain[i + 1].x, chain[i + 1].y);
ctx.stroke();
ctx.fillStyle = rgba(col.glow, 0.8);
ctx.beginPath();
ctx.arc(chain[i].x, chain[i].y, 1.6, 0, Math.PI * 2);
ctx.fill();
}
pts.push(chain[chain.length - 1]);
// 펜 — 맥동하는 절정 점
const pen = pts[pts.length - 1];
const pr = 2.6 + penPulse * 2.4;
ctx.fillStyle = rgba(col.hot, 0.28);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, Math.PI * 2);
ctx.fill();
return pen;
}
function stampPen(pen: Pt, prev: Pt): void {
// 궤적 누적 — 펜이 지나간 자리를 주색으로 쌓는다(두꺼운 선 + 옅은 확장)
const chainCtx = chainCtxRef.current;
const col = colRef.current;
if (!chainCtx || !col) return;
chainCtx.strokeStyle = rgba(col.glow, 0.85);
chainCtx.lineWidth = 1.8;
chainCtx.lineCap = "round";
chainCtx.beginPath();
chainCtx.moveTo(prev.x, prev.y);
chainCtx.lineTo(pen.x, pen.y);
chainCtx.stroke();
chainCtx.strokeStyle = rgba(col.glow, 0.2);
chainCtx.lineWidth = 4.4;
chainCtx.stroke();
}
function decayTrail(): void {
// 혜성 감쇠 — 투명 궤적 캔버스를 매 프레임 조금씩 지운다
const chainCtx = chainCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!chainCtx) return;
chainCtx.setTransform(1, 0, 0, 1, 0, 0);
chainCtx.globalCompositeOperation = "destination-out";
chainCtx.fillStyle = "rgba(0,0,0,0.028)";
chainCtx.fillRect(0, 0, W * dpr, H * dpr);
chainCtx.globalCompositeOperation = "source-over";
chainCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function stillFrame(): void {
// reduce — 궤적을 미리 한 바퀴 쌓은 뒤 마지막 장면을 고정한다
const ctx = ctxRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !col) return;
const T = termsRef.current;
let prev = computeChain(0)[T];
for (let i = 1; i <= 240; i++) {
const tt = (i / 240) * Math.PI * 2;
const pen = computeChain(tt)[T];
stampPen(pen, prev);
prev = pen;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawFrame(2.1, 0.5);
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) { t0 = now; prevPenRef.current = null; }
const t = ((now - t0) / 1000) * speedRef.current;
const pulse = 0.5 + 0.5 * Math.sin(t * 9.0);
decayTrail();
const pen = drawFrame(t, pulse);
if (prevPenRef.current) stampPen(pen, prevPenRef.current);
prevPenRef.current = pen;
setN(String(termsRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
speedRef.current = speed;
termsRef.current = terms;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setN(String(terms));
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [speed, terms]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>미리 정의한 복소 계수 몇 개로 반지름이 점점 작아지는 원들을 차례로 이어 붙이면, 맨 끝점이 평면에 꽃잎 같은 대칭 무늬를 그리며 도는데 이것이 푸리에 재합성의 최소 단위입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b> 배음 5개 · 계수 직접 정의</span>
<span className={styles.metaDim}>canvas · complex chain · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 基本のエピサイクル連鎖 — 円の上に円が載って周回し、点を打つ構造 — 計測 累積面積 36.176% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
半径が小さくなる円を順に繋ぐだけの最小構造
係数はk(周波数)・r(半径比)・p(初期位相)の3つ組で、角度k·t + pの位置へ前の円の端から次の中心を置くだけです。あと8つの盤はすべて、このCOEF配列の中身をどう用意するかの違いだと読めます。
// 係数5つ — 周波数2・3・5・7・9。位相をずらして花びらの対称が出るようにする
var COEF = [
{ k: 2, r: 0.34, p: 0.0 },
{ k: 3, r: 0.18, p: 1.1 },
{ k: 5, r: 0.12, p: 2.4 },
{ k: 7, r: 0.07, p: 0.6 },
{ k: 9, r: 0.05, p: 3.6 }
];
// 係数チェーンを角度tで回してペン位置までの中心列を計算する
function computeChain(t) {
var cx = W * 0.5, cy = H * 0.5;
var base = Math.min(W, H) * 0.48;
var pts = [{ x: cx, y: cy }];
for (var i = 0; i < TERMS && i < COEF.length; i++) {
var c = COEF[i];
var a = c.k * t + c.p;
var px = pts[i].x + Math.cos(a) * c.r * base;
var py = pts[i].y + Math.sin(a) * c.r * base;
pts.push({ x: px, y: py });
}
return pts;
}
02矩形波の合成 — 奇数倍音の円いくつかで角ある波形を作る
第1・3・5・7次の奇数倍音だけを重ねると、滑らかな円運動の和が急峻な階段へ変わる瞬間が毎フレーム描かれます。各項は半径AMP/kの円がk倍速で回るものと同じで、左に円の連鎖を縦に積み、末端の高さを右の波形領域に時間軸で展開して積んでいきます。AMPには級数の振幅係数4/π≈1.2732を使い、24フレーム中23フレームが動いて累積面積36.88%、フレーム最大面積は9.004%です。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>第1・3・5倍音の円だけを重ねると、滑らかな円運動の和が急峻な矩形波へ変わる瞬間が見えます。鋭角さえサインの和だ、というフーリエ級数の主張をそのまま画面に移したものです</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="矩形波合成 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 奇数倍音4つ・コサイン級数</span></span>
<span class="fe__meta fe__meta--dim">canvas · odd harmonics · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 구형파 합성(02). 푸리에 급수의 고전 증명 장면 —
// 구형파는 홀수 배음만 남긴 코사인 급수 f(t) = (4/π)·Σ_{k 홀수} sin(k t)/k 로分解된다.
// 각 항 sin(k t)/k 는 반지름 1/k 인 원이 k 배속으로 도는 것과 같다(오일러 공식의 허수부).
// 왼쪽에서 그 원 체인을 세로로 쌓으면 맨 끝점의 높이가 f(t) — 오른쪽 파형 영역에 t 축으로 펼쳐
// 쌓는다. 원들이 합쳐져 각진 계단이 되는 순간이 매 프레임 계산으로 그려진다.
//
// 화면이 사는 장치 — 077 기준:
// · 왼쪽 체인 뒤 극격자 스포크가 천천히 돌고 오른쪽 파형 영역에 시간 눈금이 흐른다(매 프레임 재계산).
// · 파형은 t 가 전진하며 투명 누적 캔버스에 쌓이고 혜성 감쇠로 사라진다(절대 clear 없음).
// · 링 체인 + 링크 + 가이드 수평선 + 맥동 절정 점이 매 프레임 다시 그려진다(23/23).
(function () {
// ── 변형 상수 블록 ──
var TERMS = 4; // 사용할 홀수 배음 개수 (1·3·5·7차)
var SPEED = 0.55; // 시간 전진 속도
// ──────────────────
var AMP = 1.2732395; // 4/π — 구형파 진폭 정규화
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var waveCv, waveCtx;
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
waveCv = document.createElement('canvas');
waveCv.width = W * dpr; waveCv.height = H * dpr;
waveCtx = waveCv.getContext('2d');
waveCtx.scale(dpr, dpr);
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// 밑바탕 — 왼쪽 극격자(회전 스포크) + 오른쪽 흐르는 시간 눈금 + 스캔 밴드. 매 프레임 재계산
function drawGrid(t) {
var ctx = cv.getContext('2d');
var cx = W * 0.24, cy = H * 0.5;
var maxR = Math.min(W, H) * 0.52;
var spin = t * 0.3;
for (var i = 0; i < 12; i++) {
var a = spin + (i / 12) * Math.PI * 2;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.12 : 0.06);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 10, cy + Math.sin(a) * 10);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
// 오른쪽 시간 눈금 — 세로선이 왼쪽으로 흐른다
var x0 = W * 0.46;
var flow = (t * 26) % 24;
for (var x = x0 + flow; x < W; x += 24) {
ctx.strokeStyle = rgba(C_GLOW, 0.082);
ctx.beginPath();
ctx.moveTo(x, 4); ctx.lineTo(x, H - 4);
ctx.stroke();
}
// 스캔 밴드 — 파형 영역을 가로질러 흐르는 계단 밴드(그라디언트 아님 — 12단).
// 2초 안에 오른쪽 영역 전체를 한 번 쓸고 지나가는 속도
var bx = W * 0.46 + ((t * 60) % (W * 0.58));
for (var sb = 0; sb < 12; sb++) {
ctx.fillStyle = rgba(C_GLOW, 0.095 * (1 - sb / 12));
ctx.fillRect(bx - sb * 6, 0, 6, H);
}
// 기준 레벨선(진폭 ±1) — 숨쉰다
ctx.strokeStyle = rgba(C_GLOW, 0.09 + 0.03 * Math.sin(t * 1.7));
ctx.beginPath();
ctx.moveTo(W * 0.44, cy - H * 0.36); ctx.lineTo(W, cy - H * 0.36);
ctx.moveTo(W * 0.44, cy + H * 0.36); ctx.lineTo(W, cy + H * 0.36);
ctx.stroke();
}
function computeChain(t) {
// 세로 스택: 원 k 반지름 = AMP/k, 각속도 = k
var pts = [];
var cx = W * 0.24, cy = H * 0.5;
var base = H * 0.36;
pts.push({ x: cx, y: cy });
for (var k = 1; k <= TERMS * 2; k += 2) {
var y = pts[pts.length - 1].y + Math.sin(k * t) * (AMP / k) * base;
pts.push({ x: cx, y: y });
}
return pts;
}
function draw(t, pulse) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(t);
ctx.drawImage(waveCv, 0, 0, W, H);
var pts = computeChain(t);
var base = H * 0.36;
var cx = W * 0.24;
// 파형 영역 구분선
ctx.strokeStyle = rgba(C_GLOW, 0.14);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.42, 0); ctx.lineTo(W * 0.42, H);
ctx.stroke();
// 원 체인(세로 스택)
for (var i = 0; i < pts.length - 1; i++) {
var k = i * 2 + 1;
var r = (AMP / k) * base;
ctx.strokeStyle = rgba(C_GLOW, 0.15 + 0.06 * i);
ctx.beginPath();
ctx.arc(cx, pts[i].y, Math.abs(r), 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = rgba(C_GLOW, 0.6);
ctx.beginPath();
ctx.moveTo(cx, pts[i].y);
ctx.lineTo(cx, pts[i + 1].y);
ctx.stroke();
}
var penY = pts[pts.length - 1].y;
// 펜 → 파형 절정을 잇는 가이드 수평선
ctx.strokeStyle = rgba(C_HOT, 0.4);
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(cx + 4, penY);
ctx.lineTo(W * 0.46, penY);
ctx.stroke();
ctx.setLineDash([]);
// 파형의 현재 끝점 — 맥동 절정 점
var wx = W * 0.46 + (t * W * 0.5) % (W * 0.5);
if (wx > W - 3) wx = W - 3;
var pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(wx, penY, pr * 2.3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(wx, penY, pr, 0, Math.PI * 2);
ctx.fill();
return penY;
}
function stampWave(penY, prevY, t) {
var wx = W * 0.46 + (t * W * 0.5) % (W * 0.5);
if (wx > W - 3) return;
if (prevY === null) { prevY = penY; }
waveCtx.strokeStyle = rgba(C_GLOW, 0.85);
waveCtx.lineWidth = 1.8;
waveCtx.lineCap = 'round';
waveCtx.beginPath();
waveCtx.moveTo(wx - W * 0.5 * SPEED * (1 / 60), prevY);
waveCtx.lineTo(wx, penY);
waveCtx.stroke();
waveCtx.strokeStyle = rgba(C_GLOW, 0.18);
waveCtx.lineWidth = 4.8;
waveCtx.stroke();
}
function decayWave() {
waveCtx.setTransform(1, 0, 0, 1, 0, 0);
waveCtx.globalCompositeOperation = 'destination-out';
waveCtx.fillStyle = 'rgba(0,0,0,0.02)';
waveCtx.fillRect(0, 0, W * dpr, H * dpr);
waveCtx.globalCompositeOperation = 'source-over';
waveCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var prevY = null;
function frame(now) {
if (t0 < 0) t0 = now;
var t = ((now - t0) / 1000) * SPEED * 4.2;
var pulse = 0.5 + 0.5 * Math.sin(t * 7.0);
decayWave();
var penY = draw(t, pulse);
stampWave(penY, prevY, t);
prevY = penY;
if (nOut) nOut.textContent = String(TERMS);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
var prev = null;
for (var i = 0; i <= 340; i++) {
var tt = (i / 340) * 6.4;
var y = computeChain(tt)[computeChain(0).length - 1];
stampWave(y, prev, tt);
prev = y;
}
draw(2.2, 0.5);
}
window.addEventListener('resize', function () {
fit(); prevY = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SquareWaveSynth — vanilla 판(run/321/demo/02-fourier-square-wave-synth/index.html) 의 React 포트.
// 구형파 합성. 푸리에 급수의 고전 증명 장면 — 구형파는 홀수 배음만 남긴 코사인 급수
// f(t) = (4/π)·Σ_{k 홀수} sin(k t)/k 로 분해된다. 각 항 sin(k t)/k 는 반지름 1/k 인 원이
// k 배속으로 도는 것과 같다(오일러 공식의 허수부). 왼쪽에서 그 원 체인을 세로로 쌓으면
// 맨 끝점의 높이가 f(t) — 오른쪽 파형 영역에 t 축으로 펼쳐 쌓는다.
// 화면이 사는 장치 — 왼쪽 체인 뒤 극격자 스포크가 천천히 돌고 오른쪽 파형 영역에
// 시간 눈금·스캔 밴드가 흐른다(매 프레임 재계산). 파형은 투명 누적 캔버스에 쌓이고 혜성 감쇠.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SquareWaveSynth.module.scss";
export type SquareWaveSynthProps = {
duration?: string;
easing?: string;
color?: string;
terms?: number; // 사용할 홀수 배음 개수 (1·3·5·7차)
speed?: number; // 시간 전진 속도
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
const FIELD_LABEL = "구형파 합성 실험판";
const AMP = 1.2732395; // 4/π — 구형파 진폭 정규화
export default function SquareWaveSynth({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
terms = 4,
speed = 0.55
}: SquareWaveSynthProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const waveCvRef = useRef<HTMLCanvasElement | null>(null);
const waveCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const termsRef = useRef(terms) as Box<number>;
const speedRef = useRef(speed) as Box<number>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const waveCv = document.createElement("canvas");
waveCv.width = W * dpr; waveCv.height = H * dpr;
const waveCtx = waveCv.getContext("2d");
if (waveCtx) waveCtx.scale(dpr, dpr);
waveCvRef.current = waveCv;
waveCtxRef.current = waveCtx;
}
// 밑바탕 — 왼쪽 극격자(회전 스포크) + 오른쪽 흐르는 시간 눈금 + 스캔 밴드. 매 프레임 재계산
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.24, cy = H * 0.5;
const maxR = Math.min(W, H) * 0.52;
const spin = t * 0.3;
for (let i = 0; i < 12; i++) {
const a = spin + (i / 12) * Math.PI * 2;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.12 : 0.06);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 10, cy + Math.sin(a) * 10);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
// 오른쪽 시간 눈금 — 세로선이 왼쪽으로 흐른다
const x0 = W * 0.46;
const flow = (t * 26) % 24;
for (let x = x0 + flow; x < W; x += 24) {
ctx.strokeStyle = rgba(col.glow, 0.082);
ctx.beginPath();
ctx.moveTo(x, 4); ctx.lineTo(x, H - 4);
ctx.stroke();
}
// 스캔 밴드 — 파형 영역을 가로질러 흐르는 계단 밴드(그라디언트 아님 — 12단)
const bx = W * 0.46 + ((t * 60) % (W * 0.58));
for (let sb = 0; sb < 12; sb++) {
ctx.fillStyle = rgba(col.glow, 0.095 * (1 - sb / 12));
ctx.fillRect(bx - sb * 6, 0, 6, H);
}
// 기준 레벨선(진폭 ±1) — 숨쉰다
ctx.strokeStyle = rgba(col.glow, 0.09 + 0.03 * Math.sin(t * 1.7));
ctx.beginPath();
ctx.moveTo(W * 0.44, cy - H * 0.36); ctx.lineTo(W, cy - H * 0.36);
ctx.moveTo(W * 0.44, cy + H * 0.36); ctx.lineTo(W, cy + H * 0.36);
ctx.stroke();
}
function computeChain(t: number): Pt[] {
// 세로 스택: 원 k 반지름 = AMP/k, 각속도 = k
const W = wRef.current, H = hRef.current;
const pts: Pt[] = [];
const cx = W * 0.24, cy = H * 0.5;
const base = H * 0.36;
pts.push({ x: cx, y: cy });
for (let k = 1; k <= termsRef.current * 2; k += 2) {
const y = pts[pts.length - 1].y + Math.sin(k * t) * (AMP / k) * base;
pts.push({ x: cx, y: y });
}
return pts;
}
function draw(t: number, pulse: number): number {
const ctx = ctxRef.current;
const waveCv = waveCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !waveCv || !col) return 0;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, t);
ctx.drawImage(waveCv, 0, 0, W, H);
const pts = computeChain(t);
const base = H * 0.36;
const cx = W * 0.24;
// 파형 영역 구분선
ctx.strokeStyle = rgba(col.glow, 0.14);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.42, 0); ctx.lineTo(W * 0.42, H);
ctx.stroke();
// 원 체인(세로 스택)
for (let i = 0; i < pts.length - 1; i++) {
const k = i * 2 + 1;
const r = (AMP / k) * base;
ctx.strokeStyle = rgba(col.glow, 0.15 + 0.06 * i);
ctx.beginPath();
ctx.arc(cx, pts[i].y, Math.abs(r), 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = rgba(col.glow, 0.6);
ctx.beginPath();
ctx.moveTo(cx, pts[i].y);
ctx.lineTo(cx, pts[i + 1].y);
ctx.stroke();
}
const penY = pts[pts.length - 1].y;
// 펜 → 파형 절정을 잇는 가이드 수평선
ctx.strokeStyle = rgba(col.hot, 0.4);
ctx.setLineDash([3, 4]);
ctx.beginPath();
ctx.moveTo(cx + 4, penY);
ctx.lineTo(W * 0.46, penY);
ctx.stroke();
ctx.setLineDash([]);
// 파형의 현재 끝점 — 맥동 절정 점
let wx = W * 0.46 + (t * W * 0.5) % (W * 0.5);
if (wx > W - 3) wx = W - 3;
const pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(wx, penY, pr * 2.3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(wx, penY, pr, 0, Math.PI * 2);
ctx.fill();
return penY;
}
function stampWave(penY: number, prevY: number | null, t: number): void {
const waveCtx = waveCtxRef.current;
const col = colRef.current;
const W = wRef.current;
if (!waveCtx || !col) return;
const wx = W * 0.46 + (t * W * 0.5) % (W * 0.5);
if (wx > W - 3) return;
const py = prevY === null ? penY : prevY;
waveCtx.strokeStyle = rgba(col.glow, 0.85);
waveCtx.lineWidth = 1.8;
waveCtx.lineCap = "round";
waveCtx.beginPath();
waveCtx.moveTo(wx - W * 0.5 * speedRef.current * (1 / 60), py);
waveCtx.lineTo(wx, penY);
waveCtx.stroke();
waveCtx.strokeStyle = rgba(col.glow, 0.18);
waveCtx.lineWidth = 4.8;
waveCtx.stroke();
}
function decayWave(): void {
const waveCtx = waveCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!waveCtx) return;
waveCtx.setTransform(1, 0, 0, 1, 0, 0);
waveCtx.globalCompositeOperation = "destination-out";
waveCtx.fillStyle = "rgba(0,0,0,0.02)";
waveCtx.fillRect(0, 0, W * dpr, H * dpr);
waveCtx.globalCompositeOperation = "source-over";
waveCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const prevYRef = useRef<number | null>(null) as Box<number | null>;
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = ((now - t0) / 1000) * speedRef.current * 4.2;
const pulse = 0.5 + 0.5 * Math.sin(t * 7.0);
decayWave();
const penY = draw(t, pulse);
stampWave(penY, prevYRef.current, t);
prevYRef.current = penY;
setN(String(termsRef.current));
rafRef.current = requestAnimationFrame(frame);
}
function stillFrame(): void {
let prev: number | null = null;
for (let i = 0; i <= 340; i++) {
const tt = (i / 340) * 6.4;
const chain = computeChain(tt);
const y = chain[chain.length - 1].y;
stampWave(y, prev, tt);
prev = y;
}
draw(2.2, 0.5);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
termsRef.current = terms;
speedRef.current = speed;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setN(String(terms));
function onResize(): void {
fit(); prevYRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terms, speed]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>1·3·5차 배음의 원만 겹치면 부드러운 원운동의 합이 뾰족한 구형파로 변하는 순간이 보이는데, 푸리에 급수가 각진 것도 사인의 합이라고 말하는 장면을 그대로 화면에 옮긴 것입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b> 홀수 배음 4개 · 코사인 급수</span>
<span className={styles.metaDim}>canvas · odd harmonics · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · 矩形波の合成 — 奇数倍音の円いくつかで角ある波形を作る — 計測 累積面積 36.88% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
奇数倍音の縦スタックが階段を作る
角のある波形がサインの和でできている様子を、式ではなく円の重ね方で見せる盤です。円の数を増やすほど立ち上がりは急になり、TERMS=4の4段でもう階段の輪郭がはっきり読めます。
function computeChain(t) {
// 縦スタック: 円kの半径 = AMP/k、角速度 = k
var pts = [];
var cx = W * 0.24, cy = H * 0.5;
var base = H * 0.36;
pts.push({ x: cx, y: cy });
for (var k = 1; k <= TERMS * 2; k += 2) {
var y = pts[pts.length - 1].y + Math.sin(k * t) * (AMP / k) * base;
pts.push({ x: cx, y: y });
}
return pts;
}
03星を描く — 係数サイズ順でペン先が一筆で星を描く
五芒星(外側半径1・内側0.42、10個の頂点を通る閉じた経路)を256点でサンプリングして離散フーリエ変換を走らせ、係数を大きさ順に並べ直すと、ペン先が頂点を一つも逃さず一周して元の星を再現します。再合成に使うのは大きさ順の上位14個(SHOW=14)だけで、残りの係数は使わずにここまでの形が立ち上がります。HOLD=0.45の割りだけ完成図に停留し、24フレーム中23フレームが動いて累積面積45.551%です。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>星の輪郭の点に離散フーリエ変換をかけ、振幅順に円を並べると、ペン先が頂点を一つも逃さず一周し、元の星を再現します</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="星描画エピサイクル 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 点256・振幅順ソート</span></span>
<span class="fe__meta fe__meta--dim">canvas · DFT · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 별 그리기(03). 이 편의 심장 — 경로 점 → 이산 푸리에 변환(DFT) → 원 체인 재합성.
// ① 별 윤곽을 매개변수 t(0~2π)로 256 점 샘플링해 복소평면 점 z_n = x_n + i·y_n 으로 올린다.
// ② DFT: c_k = (1/N)·Σ z_n · e^(-i·2πkn/N) — 모든 k(음수·양수 주파수)에 대해 계산.
// ③ |c_k| 큰 순서로 정렬(변형 "계수 정렬 기준"이 이 순서를 주파수 순으로 바꾼다).
// ④ 재합성: 시각 t 에 각 원이 중심 p += c_k·e^(i·k·t) 로 이어 붙는다. 맨 끝 = 원래 별 좌표.
// 붓끝이 별을 다 그리면 트레일이 잠시 머물렀다 옅어지고 다시 그린다(루프).
//
// 화면이 사는 장치 — 077 기준:
// · 밑바탕 극격자(회전 스포크·동심원)가 매 프레임 재계산되어 화면 전면이 살아 있다(누적면적 ↑).
// · 트레일은 투명 누적 캔버스에 쌓인다(절대 clear 없음).
// · 원 체인(상위 14개만 표시)·링크·붓끝 절정 점이 매 프레임 계산된다(23/23).
(function () {
// ── 변형 상수 블록 ──
var N = 256; // 경로 샘플 점 수
var SHOW = 14; // 화면에 그리는 円の数(계수는 전부 사용)
var HOLD = 0.45; // 완성 후 머무는 비율
// ──────────────────
var TAU = Math.PI * 2;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var trailCv, trailCtx;
// ── ① 별 윤곽 샘플 — 다섯 포인트 별(바깥·안 꼭짓점 교차) ──
function starPath(u) {
// u: 0~1. 외곽 반지름 1, 내곽 0.42. 열 개 꼭짓점을 지나는 닫힌 경로
var seg = u * 10;
var i = Math.floor(seg);
var f = seg - i;
var a1 = i / 10 * TAU - Math.PI / 2;
var a2 = (i + 1) / 10 * TAU - Math.PI / 2;
var outer = (i % 2 === 0);
var r1 = outer ? 1 : 0.42;
var r2 = (i % 2 === 0) ? 0.42 : 1;
var x1 = Math.cos(a1) * r1, y1 = Math.sin(a1) * r1;
var x2 = Math.cos(a2) * r2, y2 = Math.sin(a2) * r2;
return { x: x1 + (x2 - x1) * f, y: y1 + (y2 - y1) * f };
}
// ── ② DFT ──
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M; // 음수 주파수 접기
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; }); // ③ 크기 순
return out;
}
var PATH = [], COEF = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
trailCv = document.createElement('canvas');
trailCv.width = W * dpr; trailCv.height = H * dpr;
trailCtx = trailCv.getContext('2d');
trailCtx.scale(dpr, dpr);
// 경로·계수는 한 번만
if (!PATH.length) {
for (var i = 0; i < N; i++) PATH.push(starPath(i / N));
COEF = dft(PATH);
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// ── ④ 재합성 — 시각 u(0~1)에서 체인 끝점 계산 ──
function chainAt(u, show) {
var base = Math.min(W, H) * 0.4;
var cx = W * 0.5, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
var n = show || COEF.length;
for (var i = 0; i < n && i < COEF.length; i++) {
var c = COEF[i];
var a = c.freq * u * TAU;
var ph = Math.atan2(c.im, c.re);
var ang = a + ph;
var px = pts[i].x + Math.cos(ang) * c.amp * base;
var py = pts[i].y + Math.sin(ang) * c.amp * base;
pts.push({ x: px, y: py });
}
return pts;
}
// 밑바탕 — 레이더 스윕 + 극격자. 매 프레임 재계산(누적 안 함).
// 스윕은 스치는 부채꼴이 뒤로 갈수록 옅어지는 계단 밴드(그라디언트 아님 — 22단 알파).
function drawGrid(t) {
var ctx = cv.getContext('2d');
var cx = W * 0.5, cy = H * 0.5;
var maxR = Math.sqrt(cx * cx + cy * cy);
var sweep = t * 0.9;
for (var b = 0; b < 22; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.09 * (1 - b / 22));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.05, sweep - b * 0.05);
ctx.closePath();
ctx.fill();
}
// 스포크 20 — 별의 오른쪽 회전 방향과 어긋나게 돈다(양쪽 알파 모두 문턱 위)
var spin = -t * 0.3;
for (var i = 0; i < 20; i++) {
var a = spin + (i / 20) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.075 : 0.05);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
for (var k = 1; k <= 3; k++) {
var rr = (Math.min(W, H) * 0.2) * k * (1 + 0.014 * Math.sin(t * 1.1 + k * 2));
ctx.strokeStyle = rgba(C_GLOW, 0.055);
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, TAU);
ctx.stroke();
}
}
function draw(u, pulse, show, gt) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(gt);
ctx.drawImage(trailCv, 0, 0, W, H);
var base = Math.min(W, H) * 0.4;
var pts = chainAt(u, show);
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.1 + 0.02 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 3 || r > 3) {
ctx.strokeStyle = rgba(C_GLOW, 0.5);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
var pen = pts[pts.length - 1];
var pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
var prevPen = null, loopT = 0;
function stampTrail(pen) {
if (!prevPen) { prevPen = pen; return; }
trailCtx.strokeStyle = rgba(C_GLOW, 0.9);
trailCtx.lineWidth = 1.9;
trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(prevPen.x, prevPen.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(C_GLOW, 0.14);
trailCtx.lineWidth = 5;
trailCtx.stroke();
prevPen = pen;
}
var PHASE_DRAW = 1 - HOLD;
// 혜성 감쇠 — 투명 트레일 캔버스를 매 프레임 조금씩 지운다(절대 opaque fillRect 없음)
function decayTrail() {
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = 'destination-out';
trailCtx.fillStyle = 'rgba(0,0,0,0.03)';
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var wasHold = false;
function frame(now) {
if (t0 < 0) t0 = now;
var lt = ((now - t0) / 1000) * 0.5; // 한 바퀴 2초
var u = lt % 1;
var pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
var inHold = u >= PHASE_DRAW;
if (wasHold && !inHold) prevPen = null; // 머문 뒤 다음 그리기 사이클 시작점 재동기화
wasHold = inHold;
decayTrail();
var pen = draw(inHold ? 1 : u / PHASE_DRAW, pulse, SHOW, lt * TAU);
if (!inHold) stampTrail(pen);
if (nOut) nOut.textContent = String(N);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
// reduce — 별 한 바퀴 궤적을 미리 쌓은 뒤 완성 장면을 고정한다
var prev = chainAt(0)[COEF.length - 1];
for (var i = 1; i <= 200; i++) {
var pen = chainAt(i / 200)[COEF.length - 1];
stampTrail(pen);
prev = pen;
}
draw(1, 0.5, SHOW, 2.1);
}
window.addEventListener('resize', function () {
fit(); prevPen = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierStarDrawPath — vanilla 판(run/321/demo/03-fourier-star-draw-path/index.html) 의 React 포트.
// 별 그리기 — 경로 점 → 이산 푸리에 변환(DFT) → 원 체인 재합성의 심장.
// ① 별 윤곽을 매개변수 t(0~2π)로 256 점 샘플링해 복소평면 점으로 올린다.
// ② DFT: c_k = (1/N)·Σ z_n · e^(-i·2πkn/N) — 모든 k(음수·양수 주파수)에 대해 계산.
// ③ |c_k| 큰 순서로 정렬.
// ④ 재합성: 시각 t 에 각 원이 중심 p += c_k·e^(i·k·t) 로 이어 붙는다. 맨 끝 = 원래 별 좌표.
// 붓끝이 별을 다 그리면 트레일이 잠시 머물렀다 옅어지고 다시 그린다(루프).
// 화면이 사는 장치 — 레이더 스윕·극격자가 매 프레임 재계산, 트레일은 투명 누적 캔버스(절대 clear 없음).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FourierStarDrawPath.module.scss";
export type FourierStarDrawPathProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 경로 샘플 점 수
show?: number; // 화면에 그리는 원 개수(계수는 전부 사용)
hold?: number; // 완성 후 머무는 비율
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
const FIELD_LABEL = "별 그리기 에피사이클 실험판";
const TAU = Math.PI * 2;
// ── ① 별 윤곽 샘플 — 다섯 포인트 별(바깥·안 꼭짓점 교차) ──
function starPath(u: number): Pt {
// u: 0~1. 외곽 반지름 1, 내곽 0.42. 열 개 꼭짓점을 지나는 닫힌 경로
const seg = u * 10;
const i = Math.floor(seg);
const f = seg - i;
const a1 = i / 10 * TAU - Math.PI / 2;
const a2 = (i + 1) / 10 * TAU - Math.PI / 2;
const r1 = (i % 2 === 0) ? 1 : 0.42;
const r2 = (i % 2 === 0) ? 0.42 : 1;
const x1 = Math.cos(a1) * r1, y1 = Math.sin(a1) * r1;
const x2 = Math.cos(a2) * r2, y2 = Math.sin(a2) * r2;
return { x: x1 + (x2 - x1) * f, y: y1 + (y2 - y1) * f };
}
// ── ② DFT ──
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M; // 음수 주파수 접기
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp); // ③ 크기 순
return out;
}
export default function FourierStarDrawPath({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
n = 256,
show = 14,
hold = 0.45
}: FourierStarDrawPathProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nRef = useRef(n) as Box<number>;
const showRef = useRef(show) as Box<number>;
const holdRef = useRef(hold) as Box<number>;
const pathRef = useRef<Pt[]>([]) as Box<Pt[]>;
const coefRef = useRef<Coef[]>([]) as Box<Coef[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [cnt, setCnt] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const trailCv = document.createElement("canvas");
trailCv.width = W * dpr; trailCv.height = H * dpr;
const trailCtx = trailCv.getContext("2d");
if (trailCtx) trailCtx.scale(dpr, dpr);
trailCvRef.current = trailCv;
trailCtxRef.current = trailCtx;
// 경로·계수는 한 번만
if (!pathRef.current.length) {
const PATH: Pt[] = [];
for (let i = 0; i < nRef.current; i++) PATH.push(starPath(i / nRef.current));
pathRef.current = PATH;
coefRef.current = dft(PATH);
}
}
// ── ④ 재합성 — 시각 u(0~1)에서 체인 끝점 계산 ──
function chainAt(u: number, showCount?: number): Pt[] {
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
const base = Math.min(W, H) * 0.4;
const cx = W * 0.5, cy = H * 0.5;
const pts: Pt[] = [{ x: cx, y: cy }];
const n = showCount || COEF.length;
for (let i = 0; i < n && i < COEF.length; i++) {
const c = COEF[i];
const a = c.freq * u * TAU;
const ph = Math.atan2(c.im, c.re);
const ang = a + ph;
pts.push({ x: pts[i].x + Math.cos(ang) * c.amp * base, y: pts[i].y + Math.sin(ang) * c.amp * base });
}
return pts;
}
// 밑바탕 — 레이더 스윕 + 극격자. 매 프레임 재계산(누적 안 함).
// 스윕은 스치는 부채꼴이 뒤로 갈수록 옅어지는 계단 밴드(그라디언트 아님 — 22단 알파).
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.5, cy = H * 0.5;
const maxR = Math.sqrt(cx * cx + cy * cy);
const sweep = t * 0.9;
for (let b = 0; b < 22; b++) {
ctx.fillStyle = rgba(col.glow, 0.09 * (1 - b / 22));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.05, sweep - b * 0.05);
ctx.closePath();
ctx.fill();
}
// 스포크 20 — 별의 오른쪽 회전 방향과 어긋나게 돈다(양쪽 알파 모두 문턱 위)
const spin = -t * 0.3;
for (let i = 0; i < 20; i++) {
const a = spin + (i / 20) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.075 : 0.05);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
for (let k = 1; k <= 3; k++) {
const rr = (Math.min(W, H) * 0.2) * k * (1 + 0.014 * Math.sin(t * 1.1 + k * 2));
ctx.strokeStyle = rgba(col.glow, 0.055);
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, TAU);
ctx.stroke();
}
}
function draw(u: number, pulse: number, showCount: number, gt: number): Pt {
const ctx = ctxRef.current;
const trailCv = trailCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
if (!ctx || !trailCv || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, gt);
ctx.drawImage(trailCv, 0, 0, W, H);
const base = Math.min(W, H) * 0.4;
const pts = chainAt(u, showCount);
for (let i = 0; i < pts.length - 1; i++) {
const r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.1 + 0.02 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 3 || r > 3) {
ctx.strokeStyle = rgba(col.glow, 0.5);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
const pen = pts[pts.length - 1];
const pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
function stampTrail(pen: Pt): void {
const trailCtx = trailCtxRef.current;
const col = colRef.current;
const prev = prevPenRef.current;
if (!trailCtx || !col) return;
if (!prev) { prevPenRef.current = pen; return; }
trailCtx.strokeStyle = rgba(col.glow, 0.9);
trailCtx.lineWidth = 1.9;
trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(prev.x, prev.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(col.glow, 0.14);
trailCtx.lineWidth = 5;
trailCtx.stroke();
prevPenRef.current = pen;
}
// 혜성 감쇠 — 투명 트레일 캔버스를 매 프레임 조금씩 지운다(절대 opaque fillRect 없음)
function decayTrail(): void {
const trailCtx = trailCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!trailCtx) return;
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = "destination-out";
trailCtx.fillStyle = "rgba(0,0,0,0.03)";
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = "source-over";
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const wasHoldRef = useRef(false) as Box<boolean>;
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const lt = ((now - t0) / 1000) * 0.5; // 한 바퀴 2초
const u = lt % 1;
const pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
const PHASE_DRAW = 1 - holdRef.current;
const inHold = u >= PHASE_DRAW;
if (wasHoldRef.current && !inHold) prevPenRef.current = null; // 머문 뒤 다음 그리기 사이클 시작점 재동기화
wasHoldRef.current = inHold;
decayTrail();
const pen = draw(inHold ? 1 : u / PHASE_DRAW, pulse, showRef.current, lt * TAU);
if (!inHold) stampTrail(pen);
setCnt(String(nRef.current));
rafRef.current = requestAnimationFrame(frame);
}
function stillFrame(): void {
// reduce — 별 한 바퀴 궤적을 미리 쌓은 뒤 완성 장면을 고정한다
const COEF = coefRef.current;
const S = showRef.current;
let prev = chainAt(0)[COEF.length - 1];
for (let i = 1; i <= 200; i++) {
const pen = chainAt(i / 200)[COEF.length - 1];
stampTrail(pen);
prev = pen;
}
draw(1, 0.5, S, 2.1);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nRef.current = n;
showRef.current = show;
holdRef.current = hold;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setCnt(String(n));
function onResize(): void {
fit(); prevPenRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, show, hold]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>별 윤곽의 점들을 이산 푸리에 변환해 크기 순으로 원을 배열하면 붓끝이 별의 꼭짓점을 놓치지 않고 한 바퀴 돌아 원래 별을 재현합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b> 점 256개 · 계수 크기 순 정렬</span>
<span className={styles.metaDim}>canvas · DFT · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 星を描く — 係数サイズ順でペン先が一筆で星を描く — 計測 累積面積 45.551% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
256点の星を係数へ変える二重ループ
経路の各点を複素数x + iyと見なし、すべての周波数kについて積算するN=256の二重ループがDFTの本体です。N=256ならO(N²)でも一瞬で終わるので、FFTライブラリは不要です。k > N/2の係数は負の周波数(k - N)へ折り畳み、大きさ順にソートして連鎖の順番を決めます。
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M; // 負の周波数へ折り畳む
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; }); // 大きさ順ソート
return out;
}
04ハートを描く — 閉曲線の係数でハートを蘇らせる
ハートの媒介変数曲線を256点でサンプリングして同じ変換にかけ、大きさ順ソートのあと上位60個(SHOW=60)だけを描くと、連鎖が窪み(くびれ)から始まって凹んだ部分まで正確になぞります。03より多くの円を使うのは、尖った星に対してハートの滑らかな膨らみと窪みの両方を追うためで、係数を捨てても形の本質が残る圧縮の予告編です。24フレーム中23フレームが動き、累積面積46.641%・強度平均27.7です。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>ハートの媒介変数曲線の係数だけを再合成すると、円の連鎖が窪みから始まり、凹んだ部分まで正確になぞります。愛の幾何学が少数の係数に圧縮されていることの証明です</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="ハート描画エピサイクル 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 閉曲線・上位60円</span></span>
<span class="fe__meta fe__meta--dim">canvas · DFT · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 하트 그리기(04). 03 의 DFT 파이프라인을 닫힌 매개변수 곡선에 다시 적용 —
// ① 하트 곡선 x=16sin³t, y=13cos t−5cos2t−2cos3t−cos4t 를 256 점 샘플링.
// ② 같은 DFT 로 전체 계수를 구하고 크기 순 정렬, 상위 60개만 그린다(압축의 예고편, 08 슬라이더로 이어진다).
// ③ 재합성 시 체인이 꼬리(뾰족한 아래 끝)에서 출발해 양쪽 볼록·중앙 오목까지 정확히 되짚는다.
// 붓끝이 하트를 다 그리면 트레일이 잠시 머물다 옅어지고 다시 그린다(루프).
//
// 화면이 사는 장치 — 077 기준:
// · 밑바탕 극격자(방사 스윕 + 스포크)가 매 프레임 재계산되어 화면 전면이 살아 있다.
// · 트레일은 투명 누적 캔버스에 쌓인다(절대 clear 없음).
// · 원 체인(상위 60개)·링크·붓끝 절정 점이 매 프레임 계산된다(23/23).
(function () {
// ── 변형 상수 블록 ──
var N = 256; // 경로 샘플 점 수
var SHOW = 60; // 화면에 그리는 円の数
var HOLD = 0.4; // 완성 후 머무는 비율
// ──────────────────
var TAU = Math.PI * 2;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var trailCv, trailCtx;
// ── ① 하트 매개변수 곡선 ──
function heartPath(u) {
var t = u * TAU;
var x = 16 * Math.pow(Math.sin(t), 3);
var y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
return { x: x / 17, y: y / 17 };
}
// ── ② DFT ──
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M;
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; });
return out;
}
var PATH = [], COEF = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
trailCv = document.createElement('canvas');
trailCv.width = W * dpr; trailCv.height = H * dpr;
trailCtx = trailCv.getContext('2d');
trailCtx.scale(dpr, dpr);
if (!PATH.length) {
for (var i = 0; i < N; i++) PATH.push(heartPath(i / N));
COEF = dft(PATH);
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function chainAt(u, show) {
var base = Math.min(W, H) * 0.42;
var cx = W * 0.5, cy = H * 0.48;
var pts = [{ x: cx, y: cy }];
var n = show || COEF.length;
for (var i = 0; i < n && i < COEF.length; i++) {
var c = COEF[i];
var a = c.freq * u * TAU;
var ph = Math.atan2(c.im, c.re);
var ang = a + ph;
var px = pts[i].x + Math.cos(ang) * c.amp * base;
var py = pts[i].y + Math.sin(ang) * c.amp * base;
pts.push({ x: px, y: py });
}
return pts;
}
// 밑바탕 — 방사 스윕(계단 밴드) + 스포크. 하트의 대칭축과 어긋나게 돈다
function drawGrid(t) {
var ctx = cv.getContext('2d');
var cx = W * 0.5, cy = H * 0.48;
var maxR = Math.sqrt(W * W + H * H) * 0.5;
var sweep = t * 0.8;
for (var b = 0; b < 18; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.09 * (1 - b / 18));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.055, sweep - b * 0.055);
ctx.closePath();
ctx.fill();
}
var spin = t * 0.24;
for (var i = 0; i < 18; i++) {
var a = spin + (i / 18) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.08 : 0.05);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
for (var k = 1; k <= 3; k++) {
var rr = (Math.min(W, H) * 0.18) * k * (1 + 0.014 * Math.sin(t * 1.2 + k * 2));
ctx.strokeStyle = rgba(C_GLOW, 0.06);
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, TAU);
ctx.stroke();
}
}
function draw(u, pulse, show, gt) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(gt);
ctx.drawImage(trailCv, 0, 0, W, H);
var base = Math.min(W, H) * 0.42;
var pts = chainAt(u, show);
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.09 + 0.012 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(C_GLOW, 0.48);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
var pen = pts[pts.length - 1];
var pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
var prevPen = null;
function stampTrail(pen) {
if (!prevPen) { prevPen = pen; return; }
trailCtx.strokeStyle = rgba(C_HOT, 0.85);
trailCtx.lineWidth = 1.9;
trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(prevPen.x, prevPen.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(C_HOT, 0.12);
trailCtx.lineWidth = 5;
trailCtx.stroke();
prevPen = pen;
}
function decayTrail() {
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = 'destination-out';
trailCtx.fillStyle = 'rgba(0,0,0,0.03)';
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var PHASE_DRAW = 1 - HOLD, wasHold = false;
function frame(now) {
if (t0 < 0) t0 = now;
var lt = ((now - t0) / 1000) * 0.45;
var u = lt % 1;
var pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
var inHold = u >= PHASE_DRAW;
if (wasHold && !inHold) prevPen = null;
wasHold = inHold;
decayTrail();
var pen = draw(inHold ? 1 : u / PHASE_DRAW, pulse, SHOW, lt * TAU);
if (!inHold) stampTrail(pen);
if (nOut) nOut.textContent = String(SHOW);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
var prev = chainAt(0)[SHOW];
for (var i = 1; i <= 200; i++) {
var pen = chainAt(i / 200, SHOW)[SHOW];
stampTrail(pen);
prev = pen;
}
draw(1, 0.5, SHOW, 2.1);
}
window.addEventListener('resize', function () {
fit(); prevPen = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierHeartDrawPath — vanilla 판(run/321/demo/04-fourier-heart-draw-path/index.html) 의 React 포트.
// 하트 그리기 — 03 의 DFT 파이프라인을 닫힌 매개변수 곡선에 다시 적용.
// ① 하트 곡선 x=16sin³t, y=13cos t−5cos2t−2cos3t−cos4t 를 256 점 샘플링.
// ② 같은 DFT 로 전체 계수를 구하고 크기 순 정렬, 상위 60개만 그린다(압축의 예고편, 08 슬라이더로 이어진다).
// ③ 재합성 시 체인이 꼬리(뾰족한 아래 끝)에서 출발해 양쪽 볼록·중앙 오목까지 정확히 되짚는다.
// 붓끝이 하트를 다 그리면 트레일이 잠시 머물다 옅어지고 다시 그린다(루프).
// 화면이 사는 장치 — 방사 스윕 + 스포크가 매 프레임 재계산, 트레일은 투명 누적 캔버스(절대 clear 없음).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FourierHeartDrawPath.module.scss";
export type FourierHeartDrawPathProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 경로 샘플 점 수
show?: number; // 화면에 그리는 원 개수
hold?: number; // 완성 후 머무는 비율
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
const FIELD_LABEL = "하트 그리기 에피사이클 실험판";
const TAU = Math.PI * 2;
// ── ① 하트 매개변수 곡선 ──
function heartPath(u: number): Pt {
const t = u * TAU;
const x = 16 * Math.pow(Math.sin(t), 3);
const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
return { x: x / 17, y: y / 17 };
}
// ── ② DFT ──
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M;
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp);
return out;
}
export default function FourierHeartDrawPath({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
n = 256,
show = 60,
hold = 0.4
}: FourierHeartDrawPathProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nRef = useRef(n) as Box<number>;
const showRef = useRef(show) as Box<number>;
const holdRef = useRef(hold) as Box<number>;
const pathRef = useRef<Pt[]>([]) as Box<Pt[]>;
const coefRef = useRef<Coef[]>([]) as Box<Coef[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [cnt, setCnt] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const trailCv = document.createElement("canvas");
trailCv.width = W * dpr; trailCv.height = H * dpr;
const trailCtx = trailCv.getContext("2d");
if (trailCtx) trailCtx.scale(dpr, dpr);
trailCvRef.current = trailCv;
trailCtxRef.current = trailCtx;
if (!pathRef.current.length) {
const PATH: Pt[] = [];
for (let i = 0; i < nRef.current; i++) PATH.push(heartPath(i / nRef.current));
pathRef.current = PATH;
coefRef.current = dft(PATH);
}
}
function chainAt(u: number, showCount?: number): Pt[] {
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
const base = Math.min(W, H) * 0.42;
const cx = W * 0.5, cy = H * 0.48;
const pts: Pt[] = [{ x: cx, y: cy }];
const n = showCount || COEF.length;
for (let i = 0; i < n && i < COEF.length; i++) {
const c = COEF[i];
const a = c.freq * u * TAU;
const ph = Math.atan2(c.im, c.re);
const ang = a + ph;
pts.push({ x: pts[i].x + Math.cos(ang) * c.amp * base, y: pts[i].y + Math.sin(ang) * c.amp * base });
}
return pts;
}
// 밑바탕 — 방사 스윕(계단 밴드) + 스포크. 하트의 대칭축과 어긋나게 돈다
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.5, cy = H * 0.48;
const maxR = Math.sqrt(W * W + H * H) * 0.5;
const sweep = t * 0.8;
for (let b = 0; b < 18; b++) {
ctx.fillStyle = rgba(col.glow, 0.09 * (1 - b / 18));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.055, sweep - b * 0.055);
ctx.closePath();
ctx.fill();
}
const spin = t * 0.24;
for (let i = 0; i < 18; i++) {
const a = spin + (i / 18) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.08 : 0.05);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
for (let k = 1; k <= 3; k++) {
const rr = (Math.min(W, H) * 0.18) * k * (1 + 0.014 * Math.sin(t * 1.2 + k * 2));
ctx.strokeStyle = rgba(col.glow, 0.06);
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, TAU);
ctx.stroke();
}
}
function draw(u: number, pulse: number, showCount: number, gt: number): Pt {
const ctx = ctxRef.current;
const trailCv = trailCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
if (!ctx || !trailCv || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, gt);
ctx.drawImage(trailCv, 0, 0, W, H);
const base = Math.min(W, H) * 0.42;
const pts = chainAt(u, showCount);
for (let i = 0; i < pts.length - 1; i++) {
const r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.09 + 0.012 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(col.glow, 0.48);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
const pen = pts[pts.length - 1];
const pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
function stampTrail(pen: Pt): void {
const trailCtx = trailCtxRef.current;
const col = colRef.current;
const prev = prevPenRef.current;
if (!trailCtx || !col) return;
if (!prev) { prevPenRef.current = pen; return; }
trailCtx.strokeStyle = rgba(col.hot, 0.85);
trailCtx.lineWidth = 1.9;
trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(prev.x, prev.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(col.hot, 0.12);
trailCtx.lineWidth = 5;
trailCtx.stroke();
prevPenRef.current = pen;
}
function decayTrail(): void {
const trailCtx = trailCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!trailCtx) return;
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = "destination-out";
trailCtx.fillStyle = "rgba(0,0,0,0.03)";
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = "source-over";
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const wasHoldRef = useRef(false) as Box<boolean>;
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const lt = ((now - t0) / 1000) * 0.45;
const u = lt % 1;
const pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
const PHASE_DRAW = 1 - holdRef.current;
const inHold = u >= PHASE_DRAW;
if (wasHoldRef.current && !inHold) prevPenRef.current = null;
wasHoldRef.current = inHold;
decayTrail();
const pen = draw(inHold ? 1 : u / PHASE_DRAW, pulse, showRef.current, lt * TAU);
if (!inHold) stampTrail(pen);
setCnt(String(showRef.current));
rafRef.current = requestAnimationFrame(frame);
}
function stillFrame(): void {
const S = showRef.current;
let prev = chainAt(0, S)[S];
for (let i = 1; i <= 200; i++) {
const pen = chainAt(i / 200, S)[S];
stampTrail(pen);
prev = pen;
}
draw(1, 0.5, S, 2.1);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nRef.current = n;
showRef.current = show;
holdRef.current = hold;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setCnt(String(show));
function onResize(): void {
fit(); prevPenRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, show, hold]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>하트 매개변수 곡선의 계수만 재합성하면 원 체인이 꼬리에서 시작해 하트의 오목한 부분까지 정확히 되짚어 가며, 사랑의 기하학이 계수 몇 개로 압축돼 있음을 보여줍니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b> 닫힌 곡선 · 상위 60개 원</span>
<span className={styles.metaDim}>canvas · DFT · requestAnimationFrame</span>
</div>
</div>
</main>
);
}04 · ハートを描く — 閉曲線の係数でハートを蘇らせる — 計測 累積面積 46.641% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
閉じた曲線を式から点列へ
ハートの形そのものは3行の数式で与え、uを0から1まで動かしてx・yを取り出します。星の頂点でも署名の落書きでも、閉じた経路を点列にさえすれば同じパイプラインに乗るので、05の自由描画へそのまま橋渡しできます。
// ── ① ハートの媒介変数曲線 ──
function heartPath(u) {
var t = u * TAU;
var x = 16 * Math.pow(Math.sin(t), 3);
var y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
return { x: x / 17, y: y / 17 };
}
05自分の線を描く — マウスで引いたら円がその場で蘇らせる
キャンバスにpointerdownからpointermoveで線を引くと、動かすたびにその点列が即座に正規化され、複素係数へ変換されて、描いたばかりの署名や落書きを48個の円が一筆ずつ描き直します。計算はO(N²)ですが点数は最大512点(MAXPTS=512)に抑えるので一瞬で済み、入力がない間はサンプルの落書きを薄いアルファで敷いておきます。24フレーム中23フレームが動いて累積面積42.997%です。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>キャンバスにマウスで好きな線を引くと、その点が即座に複素係数へ変わり、描いたばかりの署名や落書きをエピサイクルが一筆ずつ描き直します</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="自分の線を描く 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> サンプル落書き・自分でも描いてみて</span></span>
<span class="fe__meta fe__meta--dim">canvas · live DFT · pointer events</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 내 선 그리기(05). 편의 인터랙티브 축 — 손으로 그은 선이 그대로 DFT 입력이 된다.
// ① 기본 상태: 예시 낙서(물결 서명)를 낮은 알파로 깔아두고 에피사이클이 반복해서 되그린다.
// ② pointerdown~move 로 점을 모으면(최대 512) 기존 계수를 지우고 즉시 dft() 재계산 —
// 계산은 O(N²) 이지만 N=512 로 순식간이고, 체인이 새 경로를 다시 그리기 시작한다.
// ③ 같은 재합성 루프(투명 트레일 누적 + 혜성 감쇠)를 공유한다.
//
// 화면이 사는 장치 — 077 기준:
// · 밑바탕 방사 스윕·스포크가 매 프레임 재계산되어 화면 전면이 살아 있다.
// · 손 궤적(입력)도 옅은 잔상으로 함께 남는다 — 그은 즉시 바뀌는 계수를 눈으로 읽는다.
(function () {
// ── 변형 상수 블록 ──
var MAXPTS = 512; // 입력 받는 최대 점 수
var SHOW = 48; // 화면에 그리는 円の数
// ──────────────────
var TAU = Math.PI * 2;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var trailCv, trailCtx;
// ── DFT (03·04 와 같은 계산) ──
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M;
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; });
return out;
}
var PATH = [], COEF = [];
var normPath = []; // 정규화된 경로(체인 재현용)
function normalize() {
// 그은 점을 무대 중심·단위반지름으로 정규화
var minx = 1e9, maxx = -1e9, miny = 1e9, maxy = -1e9;
for (var i = 0; i < PATH.length; i++) {
if (PATH[i].x < minx) minx = PATH[i].x;
if (PATH[i].x > maxx) maxx = PATH[i].x;
if (PATH[i].y < miny) miny = PATH[i].y;
if (PATH[i].y > maxy) maxy = PATH[i].y;
}
var cx = (minx + maxx) / 2, cy = (miny + maxy) / 2;
var span = Math.max(maxx - minx, maxy - miny) || 1;
normPath = [];
for (var j = 0; j < PATH.length; j++) {
normPath.push({ x: (PATH[j].x - cx) / span, y: (PATH[j].y - cy) / span });
}
COEF = dft(normPath);
}
function sampleDoodle() {
// 예시 낙서 — 서명 같은 물결. t 0~1 로 닫힌 물결 무늬
PATH = [];
for (var i = 0; i < 200; i++) {
var t = i / 200;
PATH.push({
x: Math.cos(t * TAU) * (0.36 + 0.16 * Math.sin(t * TAU * 3)),
y: Math.sin(t * TAU) * (0.24 + 0.12 * Math.cos(t * TAU * 2))
});
}
normalize();
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
trailCv = document.createElement('canvas');
trailCv.width = W * dpr; trailCv.height = H * dpr;
trailCtx = trailCv.getContext('2d');
trailCtx.scale(dpr, dpr);
if (!COEF.length) sampleDoodle();
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function chainAt(u, show) {
var base = Math.min(W, H) * 0.4;
var cx = W * 0.5, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
var n = show || COEF.length;
for (var i = 0; i < n && i < COEF.length; i++) {
var c = COEF[i];
var a = c.freq * u * TAU;
var ph = Math.atan2(c.im, c.re);
var ang = a + ph;
var px = pts[i].x + Math.cos(ang) * c.amp * base;
var py = pts[i].y + Math.sin(ang) * c.amp * base;
pts.push({ x: px, y: py });
}
return pts;
}
// 밑바탕 — 방사 스윕(계단 밴드) + 스포크
function drawGrid(t) {
var ctx = cv.getContext('2d');
var cx = W * 0.5, cy = H * 0.5;
var maxR = Math.sqrt(W * W + H * H) * 0.5;
var sweep = t * 1.1;
for (var b = 0; b < 16; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.085 * (1 - b / 16));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.06, sweep - b * 0.06);
ctx.closePath();
ctx.fill();
}
var spin = t * 0.26;
for (var i = 0; i < 16; i++) {
var a = spin + (i / 16) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.08 : 0.05);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
}
function draw(u, pulse, show, gt) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(gt);
// 손 궤적 잔상 — 사용자가 그은 원본(정규화 전 화면 좌표)을 옅게
if (handCv) ctx.drawImage(handCv, 0, 0, W, H);
ctx.drawImage(trailCv, 0, 0, W, H);
var base = Math.min(W, H) * 0.4;
var pts = chainAt(u, show);
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.09 + 0.01 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(C_GLOW, 0.48);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
var pen = pts[pts.length - 1];
var pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
var prevPen = null;
var handCv = null, handCtx = null; // 손 궤적 잔상 캔버스
function stampTrail(pen) {
if (!prevPen) { prevPen = pen; return; }
trailCtx.strokeStyle = rgba(C_GLOW, 0.88);
trailCtx.lineWidth = 1.9;
trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(prevPen.x, prevPen.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(C_GLOW, 0.14);
trailCtx.lineWidth = 5;
trailCtx.stroke();
prevPen = pen;
}
function decayTrail() {
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = 'destination-out';
trailCtx.fillStyle = 'rgba(0,0,0,0.03)';
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function frame(now) {
if (t0 < 0) t0 = now;
var lt = ((now - t0) / 1000) * 0.5;
var u = lt % 1;
var pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
decayTrail();
var pen = draw(u, pulse, SHOW, lt * TAU);
stampTrail(pen);
if (nOut) nOut.textContent = String(PATH.length);
raf = requestAnimationFrame(frame);
}
// ── 입력 — 그으면 즉시 DFT 재계산 ──
var drawing = false, lastPt = null;
function toField(e) {
var r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function startDraw(e) {
drawing = true; lastPt = toField(e);
PATH = [lastPt];
if (!handCv) {
handCv = document.createElement('canvas');
handCv.width = W * dpr; handCv.height = H * dpr;
handCtx = handCv.getContext('2d');
handCtx.scale(dpr, dpr);
}
handCtx.clearRect(0, 0, W, H);
prevPen = null;
e.preventDefault();
}
function moveDraw(e) {
if (!drawing) return;
var p = toField(e);
if (Math.abs(p.x - lastPt.x) + Math.abs(p.y - lastPt.y) < 4) return;
PATH.push(p);
if (PATH.length > MAXPTS) PATH.shift();
handCtx.strokeStyle = rgba(C_HOT, 0.28);
handCtx.lineWidth = 2;
handCtx.lineCap = 'round';
handCtx.beginPath();
handCtx.moveTo(lastPt.x, lastPt.y);
handCtx.lineTo(p.x, p.y);
handCtx.stroke();
lastPt = p;
normalize();
prevPen = null;
e.preventDefault();
}
function endDraw() {
if (!drawing) return;
drawing = false;
if (PATH.length > 8) normalize();
}
cv.addEventListener('pointerdown', startDraw);
cv.addEventListener('pointermove', moveDraw);
window.addEventListener('pointerup', endDraw);
function stillFrame() {
var prev = chainAt(0, SHOW)[SHOW];
for (var i = 1; i <= 200; i++) {
var pen = chainAt(i / 200, SHOW)[SHOW];
stampTrail(pen);
prev = pen;
}
draw(0.5, 0.5, SHOW, 2.1);
}
window.addEventListener('resize', function () {
fit(); prevPen = null; handCv = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierDrawYourLine — vanilla 판(run/321/demo/05-fourier-draw-your-line/index.html) 의 React 포트.
// 내 선 그리기 — 편의 인터랙티브 축. 손으로 그은 선이 그대로 DFT 입력이 된다.
// ① 기본 상태: 예시 낙서(물결 서명)를 낮은 알파로 깔아두고 에피사이클이 반복해서 되그린다.
// ② pointerdown~move 로 점을 모으면(최대 512) 기존 계수를 지우고 즉시 dft() 재계산 —
// 계산은 O(N²) 이지만 N=512 로 순식간이고, 체인이 새 경로를 다시 그리기 시작한다.
// ③ 같은 재합성 루프(투명 트레일 누적 + 혜성 감쇠)를 공유한다.
// 화면이 사는 장치 — 밑바탕 방사 스윕·스포크가 매 프레임 재계산,
// 손 궤적(입력)도 옅은 잔상으로 함께 남는다 — 그은 즉시 바뀌는 계수를 눈으로 읽는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./FourierDrawYourLine.module.scss";
export type FourierDrawYourLineProps = {
duration?: string;
easing?: string;
color?: string;
maxPts?: number; // 입력 받는 최대 점 수
show?: number; // 화면에 그리는 원 개수
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
const FIELD_LABEL = "내 선 그리기 에피사이클 실험판";
const TAU = Math.PI * 2;
// ── DFT (03·04 와 같은 계산) ──
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M;
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp);
return out;
}
export default function FourierDrawYourLine({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
maxPts = 512,
show = 48
}: FourierDrawYourLineProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const handCvRef = useRef<HTMLCanvasElement | null>(null);
const handCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const maxPtsRef = useRef(maxPts) as Box<number>;
const showRef = useRef(show) as Box<number>;
const pathRef = useRef<Pt[]>([]) as Box<Pt[]>;
const normPathRef = useRef<Pt[]>([]) as Box<Pt[]>; // 정규화된 경로(체인 재현용)
const coefRef = useRef<Coef[]>([]) as Box<Coef[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const drawingRef = useRef(false) as Box<boolean>;
const lastPtRef = useRef<Pt | null>(null) as Box<Pt | null>;
const [cnt, setCnt] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function normalize(): void {
// 그은 점을 무대 중심·단위반지름으로 정규화
const PATH = pathRef.current;
let minx = 1e9, maxx = -1e9, miny = 1e9, maxy = -1e9;
for (let i = 0; i < PATH.length; i++) {
if (PATH[i].x < minx) minx = PATH[i].x;
if (PATH[i].x > maxx) maxx = PATH[i].x;
if (PATH[i].y < miny) miny = PATH[i].y;
if (PATH[i].y > maxy) maxy = PATH[i].y;
}
const cx = (minx + maxx) / 2, cy = (miny + maxy) / 2;
const span = Math.max(maxx - minx, maxy - miny) || 1;
const norm: Pt[] = [];
for (let j = 0; j < PATH.length; j++) {
norm.push({ x: (PATH[j].x - cx) / span, y: (PATH[j].y - cy) / span });
}
normPathRef.current = norm;
coefRef.current = dft(norm);
}
function sampleDoodle(): void {
// 예시 낙서 — 서명 같은 물결. t 0~1 로 닫힌 물결 무늬
const PATH: Pt[] = [];
for (let i = 0; i < 200; i++) {
const t = i / 200;
PATH.push({
x: Math.cos(t * TAU) * (0.36 + 0.16 * Math.sin(t * TAU * 3)),
y: Math.sin(t * TAU) * (0.24 + 0.12 * Math.cos(t * TAU * 2))
});
}
pathRef.current = PATH;
normalize();
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const trailCv = document.createElement("canvas");
trailCv.width = W * dpr; trailCv.height = H * dpr;
const trailCtx = trailCv.getContext("2d");
if (trailCtx) trailCtx.scale(dpr, dpr);
trailCvRef.current = trailCv;
trailCtxRef.current = trailCtx;
handCvRef.current = null;
handCtxRef.current = null;
if (!coefRef.current.length) sampleDoodle();
}
function chainAt(u: number, showCount?: number): Pt[] {
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
const base = Math.min(W, H) * 0.4;
const cx = W * 0.5, cy = H * 0.5;
const pts: Pt[] = [{ x: cx, y: cy }];
const n = showCount || COEF.length;
for (let i = 0; i < n && i < COEF.length; i++) {
const c = COEF[i];
const a = c.freq * u * TAU;
const ph = Math.atan2(c.im, c.re);
const ang = a + ph;
pts.push({ x: pts[i].x + Math.cos(ang) * c.amp * base, y: pts[i].y + Math.sin(ang) * c.amp * base });
}
return pts;
}
// 밑바탕 — 방사 스윕(계단 밴드) + 스포크
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.5, cy = H * 0.5;
const maxR = Math.sqrt(W * W + H * H) * 0.5;
const sweep = t * 1.1;
for (let b = 0; b < 16; b++) {
ctx.fillStyle = rgba(col.glow, 0.085 * (1 - b / 16));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.06, sweep - b * 0.06);
ctx.closePath();
ctx.fill();
}
const spin = t * 0.26;
for (let i = 0; i < 16; i++) {
const a = spin + (i / 16) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.08 : 0.05);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
}
function draw(u: number, pulse: number, showCount: number, gt: number): Pt {
const ctx = ctxRef.current;
const trailCv = trailCvRef.current;
const handCv = handCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
if (!ctx || !trailCv || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, gt);
// 손 궤적 잔상 — 사용자가 그은 원본(정규화 전 화면 좌표)을 옅게
if (handCv) ctx.drawImage(handCv, 0, 0, W, H);
ctx.drawImage(trailCv, 0, 0, W, H);
const base = Math.min(W, H) * 0.4;
const pts = chainAt(u, showCount);
for (let i = 0; i < pts.length - 1; i++) {
const r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.09 + 0.01 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(col.glow, 0.48);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
const pen = pts[pts.length - 1];
const pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
function stampTrail(pen: Pt): void {
const trailCtx = trailCtxRef.current;
const col = colRef.current;
const prev = prevPenRef.current;
if (!trailCtx || !col) return;
if (!prev) { prevPenRef.current = pen; return; }
trailCtx.strokeStyle = rgba(col.glow, 0.88);
trailCtx.lineWidth = 1.9;
trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(prev.x, prev.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(col.glow, 0.14);
trailCtx.lineWidth = 5;
trailCtx.stroke();
prevPenRef.current = pen;
}
function decayTrail(): void {
const trailCtx = trailCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!trailCtx) return;
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = "destination-out";
trailCtx.fillStyle = "rgba(0,0,0,0.03)";
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = "source-over";
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const lt = ((now - t0) / 1000) * 0.5;
const u = lt % 1;
const pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
decayTrail();
const pen = draw(u, pulse, showRef.current, lt * TAU);
stampTrail(pen);
setCnt(String(pathRef.current.length));
rafRef.current = requestAnimationFrame(frame);
}
// ── 입력 — 그으면 즉시 DFT 재계산 ──
function toField(e: ReactPointerEvent<HTMLCanvasElement>): Pt {
const cv = cvRef.current;
if (!cv) return { x: 0, y: 0 };
const r = cv.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function startDraw(e: ReactPointerEvent<HTMLCanvasElement>): void {
const col = colRef.current;
const W = wRef.current, H = hRef.current;
const dpr = dprRef.current;
if (!col) return;
drawingRef.current = true;
const p = toField(e);
lastPtRef.current = p;
pathRef.current = [p];
if (!handCvRef.current) {
const handCv = document.createElement("canvas");
handCv.width = W * dpr; handCv.height = H * dpr;
const handCtx = handCv.getContext("2d");
if (handCtx) handCtx.scale(dpr, dpr);
handCvRef.current = handCv;
handCtxRef.current = handCtx;
}
const handCtx = handCtxRef.current;
if (handCtx) handCtx.clearRect(0, 0, W, H);
prevPenRef.current = null;
e.preventDefault();
}
function moveDraw(e: ReactPointerEvent<HTMLCanvasElement>): void {
const handCtx = handCtxRef.current;
const col = colRef.current;
const last = lastPtRef.current;
if (!drawingRef.current || !handCtx || !col || !last) return;
const p = toField(e);
if (Math.abs(p.x - last.x) + Math.abs(p.y - last.y) < 4) return;
pathRef.current.push(p);
if (pathRef.current.length > maxPtsRef.current) pathRef.current.shift();
handCtx.strokeStyle = rgba(col.hot, 0.28);
handCtx.lineWidth = 2;
handCtx.lineCap = "round";
handCtx.beginPath();
handCtx.moveTo(last.x, last.y);
handCtx.lineTo(p.x, p.y);
handCtx.stroke();
lastPtRef.current = p;
normalize();
prevPenRef.current = null;
e.preventDefault();
}
function endDraw(): void {
if (!drawingRef.current) return;
drawingRef.current = false;
if (pathRef.current.length > 8) normalize();
}
function stillFrame(): void {
const S = showRef.current;
let prev = chainAt(0, S)[S];
for (let i = 1; i <= 200; i++) {
const pen = chainAt(i / 200, S)[S];
stampTrail(pen);
prev = pen;
}
draw(0.5, 0.5, S, 2.1);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
maxPtsRef.current = maxPts;
showRef.current = show;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setCnt(String(pathRef.current.length));
function onResize(): void {
fit(); prevPenRef.current = null;
if (reduced()) stillFrame();
}
function onPointerUp(): void {
endDraw();
}
window.addEventListener("resize", onResize);
window.addEventListener("pointerup", onPointerUp);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
window.removeEventListener("pointerup", onPointerUp);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [maxPts, show]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>캔버스에 마우스로 아무 선이나 그으면 그 점들이 즉시 복소 계수로 바뀌어, 방금 손으로 그은 서명 낙서를 에피사이클이 한 획씩 다시 그려줍니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas
className={styles.cv}
ref={cvRef}
onPointerDown={startDraw}
onPointerMove={moveDraw}
></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b> 예시 낙서 · 직접 그려 보세요</span>
<span className={styles.metaDim}>canvas · live DFT · pointer events</span>
</div>
</div>
</main>
);
}05 · 自分の線を描く — マウスで引いたら円がその場で蘇らせる — 計測 累積面積 42.997% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
線を引くたびに係数が生まれ変わる
moveDrawが点を4px以上動いたぶんだけPATHへ積み、normalize()で中心と幅を揃えてからDFTをかけ直します。ペン先の記憶prevPenを毎回捨てるので、係数が入れ替わった瞬間から軌跡が新しい絵へ滑らかに切り替わります。
// ── 入力 — 線を描くと即座にDFTを再計算 ──
function moveDraw(e) {
if (!drawing) return;
var p = toField(e);
if (Math.abs(p.x - lastPt.x) + Math.abs(p.y - lastPt.y) < 4) return;
PATH.push(p);
if (PATH.length > MAXPTS) PATH.shift();
handCtx.strokeStyle = rgba(C_HOT, 0.28);
handCtx.lineWidth = 2;
handCtx.lineCap = 'round';
handCtx.beginPath();
handCtx.moveTo(lastPt.x, lastPt.y);
handCtx.lineTo(p.x, p.y);
handCtx.stroke();
lastPt = p;
normalize();
prevPen = null;
e.preventDefault();
}
06スペクトラムバー — 係数の大きさが示す『どの円が働いているか』
星と「均一スペクトラム」の落書き(多くの周波数が似た大きさで混ざった波模様)の2つのDFT係数を周波数スロット±12にバーで立てて見せながら、両者の係数を振幅のlerpと位相の混合でMORPH=2.6秒周期で往復補間すると、絵の指紋が星(低い周波数のわずかだけが働く)から落書き(均一な櫛)へ連続変形します。バーの帯が画面の狭い領域を占める項目の性格上、フレーム最大面積は9種で最も小さい2.672%ですが、強度平均33.1は01と並んで最も明るい値です。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>エピサイクルの横に周波数ごとの係数大きさをバーで立てると、星は低い周波数わずかが働き、落書きは均一な櫛が並ぶ — 図形の指紋が現れます</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="スペクトラムバー 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 星から落書きへ・連続変形</span></span>
<span class="fe__meta fe__meta--dim">canvas · |c_k| spectrum · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 스펙트럼 막대(06). 주파수 영역의 시각화 — |c_k| 를 주파수 축에 세운다.
// ① 왼쪽 절반: 에피사이클 체인(03 의 별 파이프라인 재사용)이 그림을 그린다.
// ② 오른쪽 절반: 그 그림의 계수 크기 |c_k| 를 주파수 순(-M/2 ~ +M/2) 막대그래프로 세운다.
// ③ 시간이 흐르며 별 → 낙서로 계수가 연속 보간되어 막대 무늬(지문)가 실시간으로 바뀐다.
// 별은 저주파 몇 개가 크고, 낙서는 고른 빗이 선다 — 이 대비가 이 항목의 주제다.
//
// 화면이 사는 장치 — 077 기준:
// · 막대 전체가 매 프레임 다시 그려진다(높이·색이 계속 흔들린다).
// · 밑바탕 수평 기준선·스캔 밴드가 흐른다. 체인 쪽 방사 스윕도 함께 돈다.
(function () {
// ── 변형 상수 블록 ──
var NBARS = 24; // 막대 개수(표시할 주파수 슬롯, ±12)
var MORPH = 2.6; // 별↔낙서 보간 주기(초)
// ──────────────────
var TAU = Math.PI * 2;
var N = 128;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
// ── 두 그림의 경로·계수 ──
function starPath(u) {
var seg = u * 10;
var i = Math.floor(seg);
var f = seg - i;
var a1 = i / 10 * TAU - Math.PI / 2;
var a2 = (i + 1) / 10 * TAU - Math.PI / 2;
var r1 = (i % 2 === 0) ? 1 : 0.42;
var r2 = (i % 2 === 0) ? 0.42 : 1;
var x1 = Math.cos(a1) * r1, y1 = Math.sin(a1) * r1;
var x2 = Math.cos(a2) * r2, y2 = Math.sin(a2) * r2;
return { x: x1 + (x2 - x1) * f, y: y1 + (y2 - y1) * f };
}
function doodlePath(u) {
// 고른 스펙트럼이 나오는 낙서 — 여러 주파수가 비슷한 크기로 섞인 물결 무늬
var t = u * TAU;
return {
x: Math.cos(t) * (0.32 + 0.18 * Math.sin(t * 3) + 0.1 * Math.sin(t * 7 + 1)),
y: Math.sin(t) * (0.3 + 0.16 * Math.cos(t * 5) + 0.09 * Math.sin(t * 8 + 2))
};
}
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M;
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; });
return out;
}
var STAR = [], DOODLE = [];
var STARC = [], DOODLEC = [];
// 주파수 슬롯: freq → 두 그림의 계수 매핑(보간용)
var slotStar = {}, slotDoodle = {};
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
if (!STAR.length) {
for (var i = 0; i < N; i++) {
STAR.push(starPath(i / N));
DOODLE.push(doodlePath(i / N));
}
STARC = dft(STAR);
DOODLEC = dft(DOODLE);
for (var a = 0; a < STARC.length; a++) slotStar[STARC[a].freq] = STARC[a];
for (var b = 0; b < DOODLEC.length; b++) slotDoodle[DOODLEC[b].freq] = DOODLEC[b];
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// 보간 계수 — mix 0(별)~1(낙서). 진폭은 lerp, 위상은 그대로(별의 위상 우선)
function coefAt(mix) {
var used = [];
for (var fi = -NBARS / 2; fi <= NBARS / 2; fi++) {
if (fi === 0 && mix < 0.5) continue;
var s = slotStar[fi], d = slotDoodle[fi];
if (!s || !d) continue;
used.push({
freq: fi,
amp: s.amp + (d.amp - s.amp) * mix,
ph: s.ph !== undefined ? s.ph + (d.ph - s.ph) * mix : 0
});
}
// 위상 채우기(첫 계산 후 고정)
for (var i = 0; i < used.length; i++) {
var s2 = slotStar[used[i].freq], d2 = slotDoodle[used[i].freq];
used[i].ph = Math.atan2(s2.im, s2.re) + (Math.atan2(d2.im, d2.re) - Math.atan2(s2.im, s2.re)) * mix;
}
return used;
}
var CO = []; // 최근 보간 계수(막대 그리기에 재사용)
function chainAt(u, co) {
var base = Math.min(W, H) * 0.34;
var cx = W * 0.25, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
co.sort(function (a, b) { return b.amp - a.amp; });
for (var i = 0; i < co.length && i < 20; i++) {
var c = co[i];
var ang = c.freq * u * TAU + c.ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
function draw(t) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var mix = 0.5 - 0.5 * Math.cos(t * TAU / MORPH); // 0↔1 왕복
CO = coefAt(mix);
// ── 왼쪽: 체인 ──
var cx = W * 0.25, cy = H * 0.5;
var maxR = Math.min(W, H) * 0.52;
var sweep = t * 0.85;
for (var b = 0; b < 16; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.1 * (1 - b / 16));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.06, sweep - b * 0.06);
ctx.closePath();
ctx.fill();
}
var spin = t * 0.3;
for (var i = 0; i < 14; i++) {
var a = spin + (i / 14) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.1 : 0.06);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 10, cy + Math.sin(a) * 10);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
var u = (t * 0.5) % 1;
var pts = chainAt(u, CO);
var base = Math.min(W, H) * 0.34;
for (var ci = 0; ci < pts.length - 1; ci++) {
var r = CO[ci].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.1 + 0.01 * ci);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[ci].x, pts[ci].y, r, 0, TAU);
ctx.stroke();
}
ctx.strokeStyle = rgba(C_GLOW, 0.5);
ctx.beginPath();
ctx.moveTo(pts[ci].x, pts[ci].y);
ctx.lineTo(pts[ci + 1].x, pts[ci + 1].y);
ctx.stroke();
}
var pen = pts[pts.length - 1];
var pulse = 0.5 + 0.5 * Math.sin(t * 8);
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, (2.4 + pulse * 2.2) * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, 2.4 + pulse * 2.2, 0, TAU);
ctx.fill();
// ── 오른쪽: 스펙트럼 ──
var gx = W * 0.46, gw = W * 0.5, baseY = H * 0.78;
// 구분선
ctx.strokeStyle = rgba(C_GLOW, 0.16);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.43, 0); ctx.lineTo(W * 0.43, H);
ctx.stroke();
// 기준선(밑변·중앙) — 숨쉰다
ctx.strokeStyle = rgba(C_GLOW, 0.1 + 0.03 * Math.sin(t * 1.6));
ctx.beginPath();
ctx.moveTo(gx, baseY); ctx.lineTo(gx + gw, baseY);
ctx.moveTo(gx, H * 0.3); ctx.lineTo(gx + gw, H * 0.3);
ctx.stroke();
// 스캔 밴드 — 막대 영역을 흐른다(계단 12단)
var bx = gx + ((t * 60) % (gw + 40)) - 20;
for (var sb = 0; sb < 12; sb++) {
ctx.fillStyle = rgba(C_GLOW, 0.09 * (1 - sb / 12));
ctx.fillRect(bx - sb * 5, 0, 5, H);
}
// 막대 — freq -12~+12 를 좌우로. 현재 mix 의 진폭
var bw = gw / (NBARS + 1);
var maxAmp = 0.02;
for (var q = 0; q < CO.length; q++) if (CO[q].amp > maxAmp) maxAmp = CO[q].amp;
for (var qq = 0; qq < CO.length; qq++) {
var c2 = CO[qq];
var x = gx + gw / 2 + c2.freq * bw;
var hbar = (c2.amp / maxAmp) * (H * 0.4);
var jitter = 1 + 0.06 * Math.sin(t * 5 + c2.freq);
ctx.fillStyle = rgba(C_GLOW, 0.4);
ctx.fillRect(x - bw * 0.28, baseY - hbar * jitter, bw * 0.56, hbar * jitter);
ctx.fillStyle = rgba(C_HOT, 0.9);
ctx.fillRect(x - bw * 0.28, baseY - hbar * jitter, bw * 0.56, 2.4);
}
return pen;
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
draw(t);
if (nOut) nOut.textContent = String(NBARS);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
draw(MORPH * 0.25);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierSpectrumMagnitude — vanilla 판(run/321/demo/06-fourier-spectrum-magnitude/index.html) 의 React 포트.
// 스펙트럼 막대 — 주파수 영역의 시각화. |c_k| 를 주파수 축에 세운다.
// ① 왼쪽 절반: 에피사이클 체인(03 의 별 파이프라인 재사용)이 그림을 그린다.
// ② 오른쪽 절반: 그 그림의 계수 크기 |c_k| 를 주파수 순(-M/2 ~ +M/2) 막대그래프로 세운다.
// ③ 시간이 흐르며 별 → 낙서로 계수가 연속 보간되어 막대 무늬(지문)가 실시간으로 바뀐다.
// 별은 저주파 몇 개가 크고, 낙서는 고른 빗이 선다 — 이 대비가 이 항목의 주제다.
// 화면이 사는 장치 — 막대 전체가 매 프레임 다시 그려지고(높이·색이 계속 흔들린다),
// 밑바탕 수평 기준선·스캔 밴드가 흐른다. 체인 쪽 방사 스윕도 함께 돈다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FourierSpectrumMagnitude.module.scss";
export type FourierSpectrumMagnitudeProps = {
duration?: string;
easing?: string;
color?: string;
nbars?: number; // 막대 개수(표시할 주파수 슬롯, ±nbars/2)
morph?: number; // 별↔낙서 보간 주기(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
type Used = { freq: number; amp: number; ph: number };
const FIELD_LABEL = "스펙트럼 막대 실험판";
const TAU = Math.PI * 2;
const N = 128;
// ── 두 그림의 경로 ──
function starPath(u: number): Pt {
const seg = u * 10;
const i = Math.floor(seg);
const f = seg - i;
const a1 = i / 10 * TAU - Math.PI / 2;
const a2 = (i + 1) / 10 * TAU - Math.PI / 2;
const r1 = (i % 2 === 0) ? 1 : 0.42;
const r2 = (i % 2 === 0) ? 0.42 : 1;
const x1 = Math.cos(a1) * r1, y1 = Math.sin(a1) * r1;
const x2 = Math.cos(a2) * r2, y2 = Math.sin(a2) * r2;
return { x: x1 + (x2 - x1) * f, y: y1 + (y2 - y1) * f };
}
function doodlePath(u: number): Pt {
// 고른 스펙트럼이 나오는 낙서 — 여러 주파수가 비슷한 크기로 섞인 물결 무늬
const t = u * TAU;
return {
x: Math.cos(t) * (0.32 + 0.18 * Math.sin(t * 3) + 0.1 * Math.sin(t * 7 + 1)),
y: Math.sin(t) * (0.3 + 0.16 * Math.cos(t * 5) + 0.09 * Math.sin(t * 8 + 2))
};
}
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M;
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp);
return out;
}
export default function FourierSpectrumMagnitude({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
nbars = 24,
morph = 2.6
}: FourierSpectrumMagnitudeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const nbarsRef = useRef(nbars) as Box<number>;
const morphRef = useRef(morph) as Box<number>;
const starRef = useRef<Pt[]>([]) as Box<Pt[]>;
const doodleRef = useRef<Pt[]>([]) as Box<Pt[]>;
const starCRef = useRef<Coef[]>([]) as Box<Coef[]>;
const doodleCRef = useRef<Coef[]>([]) as Box<Coef[]>;
const slotStarRef = useRef<Record<number, Coef>>({}) as Box<Record<number, Coef>>;
const slotDoodleRef = useRef<Record<number, Coef>>({}) as Box<Record<number, Coef>>;
const coRef = useRef<Used[]>([]) as Box<Used[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [cnt, setCnt] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
if (!starRef.current.length) {
const STAR: Pt[] = [], DOODLE: Pt[] = [];
for (let i = 0; i < N; i++) {
STAR.push(starPath(i / N));
DOODLE.push(doodlePath(i / N));
}
const STARC = dft(STAR);
const DOODLEC = dft(DOODLE);
starRef.current = STAR; doodleRef.current = DOODLE;
starCRef.current = STARC; doodleCRef.current = DOODLEC;
const slotStar: Record<number, Coef> = {};
for (let a = 0; a < STARC.length; a++) slotStar[STARC[a].freq] = STARC[a];
const slotDoodle: Record<number, Coef> = {};
for (let b = 0; b < DOODLEC.length; b++) slotDoodle[DOODLEC[b].freq] = DOODLEC[b];
slotStarRef.current = slotStar;
slotDoodleRef.current = slotDoodle;
}
}
// 보간 계수 — mix 0(별)~1(낙서). 진폭은 lerp, 위상은 별의 위상 우선
function coefAt(mix: number): Used[] {
const slotStar = slotStarRef.current;
const slotDoodle = slotDoodleRef.current;
const used: Used[] = [];
const NB = nbarsRef.current;
for (let fi = -NB / 2; fi <= NB / 2; fi++) {
if (fi === 0 && mix < 0.5) continue;
const s = slotStar[fi], d = slotDoodle[fi];
if (!s || !d) continue;
used.push({
freq: fi,
amp: s.amp + (d.amp - s.amp) * mix,
ph: 0
});
}
// 위상 채우기
for (let i = 0; i < used.length; i++) {
const s2 = slotStar[used[i].freq], d2 = slotDoodle[used[i].freq];
used[i].ph = Math.atan2(s2.im, s2.re) + (Math.atan2(d2.im, d2.re) - Math.atan2(s2.im, s2.re)) * mix;
}
return used;
}
function chainAt(u: number, co: Used[]): Pt[] {
const W = wRef.current, H = hRef.current;
const base = Math.min(W, H) * 0.34;
const cx = W * 0.25, cy = H * 0.5;
const pts: Pt[] = [{ x: cx, y: cy }];
co.sort((a, b) => b.amp - a.amp);
for (let i = 0; i < co.length && i < 20; i++) {
const c = co[i];
const ang = c.freq * u * TAU + c.ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
function draw(t: number): Pt {
const ctx = ctxRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const NB = nbarsRef.current;
if (!ctx || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
const mix = 0.5 - 0.5 * Math.cos(t * TAU / morphRef.current); // 0↔1 왕복
const CO = coefAt(mix);
coRef.current = CO;
// ── 왼쪽: 체인 ──
const cx = W * 0.25, cy = H * 0.5;
const maxR = Math.min(W, H) * 0.52;
const sweep = t * 0.85;
for (let b = 0; b < 16; b++) {
ctx.fillStyle = rgba(col.glow, 0.1 * (1 - b / 16));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.06, sweep - b * 0.06);
ctx.closePath();
ctx.fill();
}
const spin = t * 0.3;
for (let i = 0; i < 14; i++) {
const a = spin + (i / 14) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.1 : 0.06);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 10, cy + Math.sin(a) * 10);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
const u = (t * 0.5) % 1;
const pts = chainAt(u, CO);
const base = Math.min(W, H) * 0.34;
for (let ci = 0; ci < pts.length - 1; ci++) {
const r = CO[ci].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.1 + 0.01 * ci);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[ci].x, pts[ci].y, r, 0, TAU);
ctx.stroke();
}
ctx.strokeStyle = rgba(col.glow, 0.5);
ctx.beginPath();
ctx.moveTo(pts[ci].x, pts[ci].y);
ctx.lineTo(pts[ci + 1].x, pts[ci + 1].y);
ctx.stroke();
}
const pen = pts[pts.length - 1];
const pulse = 0.5 + 0.5 * Math.sin(t * 8);
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, (2.4 + pulse * 2.2) * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, 2.4 + pulse * 2.2, 0, TAU);
ctx.fill();
// ── 오른쪽: 스펙트럼 ──
const gx = W * 0.46, gw = W * 0.5, baseY = H * 0.78;
// 구분선
ctx.strokeStyle = rgba(col.glow, 0.16);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W * 0.43, 0); ctx.lineTo(W * 0.43, H);
ctx.stroke();
// 기준선(밑변·중앙) — 숨쉰다
ctx.strokeStyle = rgba(col.glow, 0.1 + 0.03 * Math.sin(t * 1.6));
ctx.beginPath();
ctx.moveTo(gx, baseY); ctx.lineTo(gx + gw, baseY);
ctx.moveTo(gx, H * 0.3); ctx.lineTo(gx + gw, H * 0.3);
ctx.stroke();
// 스캔 밴드 — 막대 영역을 흐른다(계단 12단)
const bx = gx + ((t * 60) % (gw + 40)) - 20;
for (let sb = 0; sb < 12; sb++) {
ctx.fillStyle = rgba(col.glow, 0.09 * (1 - sb / 12));
ctx.fillRect(bx - sb * 5, 0, 5, H);
}
// 막대 — freq -12~+12 를 좌우로. 현재 mix 의 진폭
const bw = gw / (NB + 1);
let maxAmp = 0.02;
for (let q = 0; q < CO.length; q++) if (CO[q].amp > maxAmp) maxAmp = CO[q].amp;
for (let qq = 0; qq < CO.length; qq++) {
const c2 = CO[qq];
const x = gx + gw / 2 + c2.freq * bw;
const hbar = (c2.amp / maxAmp) * (H * 0.4);
const jitter = 1 + 0.06 * Math.sin(t * 5 + c2.freq);
ctx.fillStyle = rgba(col.glow, 0.4);
ctx.fillRect(x - bw * 0.28, baseY - hbar * jitter, bw * 0.56, hbar * jitter);
ctx.fillStyle = rgba(col.hot, 0.9);
ctx.fillRect(x - bw * 0.28, baseY - hbar * jitter, bw * 0.56, 2.4);
}
return pen;
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
draw(t);
setCnt(String(nbarsRef.current));
rafRef.current = requestAnimationFrame(frame);
}
function stillFrame(): void {
draw(morphRef.current * 0.25);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nbarsRef.current = nbars;
morphRef.current = morph;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setCnt(String(nbars));
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nbars, morph]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>에피사이클 옆에 주파수별 계수 크기를 막대로 세우면, 별은 낮은 주파수 몇 개가 일하고 낙서는 고른 막대가 늘어선다는 식으로 그림의 지문이 드러납니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b> 별에서 낙서로 · 지문 연속 변형</span>
<span className={styles.metaDim}>canvas · |c_k| spectrum · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · スペクトラムバー — 係数の大きさが示す『どの円が働いているか』 — 計測 累積面積 36.266% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
振幅のlerpと位相の混合で指紋が変わる
coefAtが周波数ごとに星の係数と落書きの係数を1つのmixで混ぜます。振幅は単純な線形補間、位相はatan2で取り出した両者の角度を同じ比率で混ぜるので、棒の高さと並びの両方が同時に変わり、モーフが円の連鎖とバーの両方に現れます。
// 補間係数 — mix 0(星)〜1(落書き)。振幅はlerp、位相は星の位相を優先
function coefAt(mix) {
var used = [];
for (var fi = -NBARS / 2; fi <= NBARS / 2; fi++) {
if (fi === 0 && mix < 0.5) continue;
var s = slotStar[fi], d = slotDoodle[fi];
if (!s || !d) continue;
used.push({
freq: fi,
amp: s.amp + (d.amp - s.amp) * mix,
ph: s.ph !== undefined ? s.ph + (d.ph - s.ph) * mix : 0
});
}
// 位相を埋める(初回計算後に固定)
for (var i = 0; i < used.length; i++) {
var s2 = slotStar[used[i].freq], d2 = slotDoodle[used[i].freq];
used[i].ph = Math.atan2(s2.im, s2.re) + (Math.atan2(d2.im, d2.re) - Math.atan2(s2.im, s2.re)) * mix;
}
return used;
}
07位相モーフ — 係数はそのまま、位相だけ回すと模様が変身する
星の経路(03と同一)の係数で振幅を凍結し、位相にだけ周波数比例のオフセットφ_k = d·kを足すと、ペン先の軌跡が星から花びら・渦へ連続変身します。dはSWEEP=2.4秒周期のサイン波で-0.8〜0.8の範囲を往復し、軌跡は透明キャンバスに積まれてモーフが通った跡が残像として重なります。1フレームあたりの変化は9種で最も大きく、フレーム最大面積15.098%を記録しました。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>係数の大きさを保ち、位相にだけオフセットを与えると、模様が星から花びら、渦へと連続変形します。大きさと位相が図形で何を担うかを示す実験です</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="位相モーフ 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 位相オフセット振動・大きさ固定</span></span>
<span class="fe__meta fe__meta--dim">canvas · phase offset · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 위상 모프(07). 계수의 두 축 — 크기와 위상 — 중 위상만 흔든다.
// ① 별 경로(03 과 동일)의 DFT 계수에서 진폭은 얼리고 위상에만 오프셋 φ_k = d·k 를 더한다.
// ② d 가 0 이면 원래 별. d 가 커질수록 각 원의 시각이 어긋나 무늬가 별→꽃잎→소용돌이로 변한다.
// ③ d 를 사인파로 왕복시키면 무늬가 계속 모프한다 — 크기는 무늬의 "에너지"·위상은 "배치"임을 보여준다.
// 두 번째 시각화: 같은 계수를 오른쪽 위상 원반(각 계수를 단위원 위의 점으로)에 띄운다 —
// 위상이 흔들리면 점들이 원반을 따라 미끄러진다.
//
// 화면이 사는 장치 — 077 기준:
// · 궤적이 투명 캔버스에 쌓이며 혜성 감쇠 — 모프가 지나간 자리가 잔상으로 겹친다.
// · 방사 스윕·스포크가 매 프레임 재계산된다. 위상 원반 점들이 매 프레임 미끄러진다.
(function () {
// ── 변형 상수 블록 ──
var N = 256;
var SHOW = 16; // 체인에 쓰는 계수 수
var SWEEP = 2.4; // 위상 오프셋 왕복 주기(초)
// ──────────────────
var TAU = Math.PI * 2;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var trailCv, trailCtx;
function starPath(u) {
var seg = u * 10;
var i = Math.floor(seg);
var f = seg - i;
var a1 = i / 10 * TAU - Math.PI / 2;
var a2 = (i + 1) / 10 * TAU - Math.PI / 2;
var r1 = (i % 2 === 0) ? 1 : 0.42;
var r2 = (i % 2 === 0) ? 0.42 : 1;
var x1 = Math.cos(a1) * r1, y1 = Math.sin(a1) * r1;
var x2 = Math.cos(a2) * r2, y2 = Math.sin(a2) * r2;
return { x: x1 + (x2 - x1) * f, y: y1 + (y2 - y1) * f };
}
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M;
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; });
return out;
}
var PATH = [], COEF = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
trailCv = document.createElement('canvas');
trailCv.width = W * dpr; trailCv.height = H * dpr;
trailCtx = trailCv.getContext('2d');
trailCtx.scale(dpr, dpr);
if (!PATH.length) {
for (var i = 0; i < N; i++) PATH.push(starPath(i / N));
COEF = dft(PATH);
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
// 위상 오프셋 체인 — d 만큼 각 계수 위상을 민다
function chainAt(u, d, show) {
var base = Math.min(W, H) * 0.36;
var cx = W * 0.32, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
for (var i = 0; i < show && i < COEF.length; i++) {
var c = COEF[i];
var ph = Math.atan2(c.im, c.re) + c.freq * d;
var ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
function drawGrid(t) {
var ctx = cv.getContext('2d');
// 왼쪽 체인 자리 스윕
var cx = W * 0.32, cy = H * 0.5;
var maxR = Math.min(W, H) * 0.52;
var sweep = t * 0.8;
for (var b = 0; b < 16; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.1 * (1 - b / 16));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.06, sweep - b * 0.06);
ctx.closePath();
ctx.fill();
}
var spin = t * 0.3;
for (var i = 0; i < 14; i++) {
var a = spin + (i / 14) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.1 : 0.06);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
// 오른쪽 위상 원반 자리 — 반대 방향 스윕(계단 밴드)
var px = W * 0.78, py = H * 0.5, prad = Math.min(W, H) * 0.42;
var sweep2 = -t * 0.95;
for (var b2 = 0; b2 < 12; b2++) {
ctx.fillStyle = rgba(C_GLOW, 0.1 * (1 - b2 / 12));
ctx.beginPath();
ctx.moveTo(px, py);
ctx.arc(px, py, prad, sweep2 - (b2 + 1) * 0.07, sweep2 - b2 * 0.07);
ctx.closePath();
ctx.fill();
}
// 하단 가로 스캔 밴드 — 전체 폭을 흐른다(계단 12단)
var bandY = H - 18 - ((t * 22) % (H - 24));
for (var sb = 0; sb < 12; sb++) {
ctx.fillStyle = rgba(C_GLOW, 0.08 * (1 - sb / 12));
ctx.fillRect(0, bandY + sb * 5, W, 5);
}
}
function draw(u, pulse, d, gt) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(gt);
ctx.drawImage(trailCv, 0, 0, W, H);
var pts = chainAt(u, d, SHOW);
var base = Math.min(W, H) * 0.36;
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.09 + 0.01 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(C_GLOW, 0.46);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
var pen = pts[pts.length - 1];
var pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
// ── 오른쪽 위상 원반 — 상위 계수의 위상을 단위원 위 점으로 ──
var px = W * 0.78, py = H * 0.5, prad = Math.min(W, H) * 0.3;
ctx.strokeStyle = rgba(C_GLOW, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px, py, prad, 0, TAU);
ctx.stroke();
ctx.strokeStyle = rgba(C_GLOW, 0.1);
ctx.beginPath();
ctx.moveTo(px - prad - 6, py); ctx.lineTo(px + prad + 6, py);
ctx.moveTo(px, py - prad - 6); ctx.lineTo(px, py + prad + 6);
ctx.stroke();
// 계수별 위상 각도(오프셋 적용 뒤)에 점 — 주파수 비례 반지름
for (var k = 0; k < 12 && k < COEF.length; k++) {
var c = COEF[k];
var ph2 = Math.atan2(c.im, c.re) + c.freq * d;
var rr = prad * (1 - k / 14);
var qx = px + Math.cos(ph2) * rr, qy = py + Math.sin(ph2) * rr;
ctx.fillStyle = rgba(k === 0 ? C_HOT : C_GLOW, 0.85);
ctx.beginPath();
ctx.arc(qx, qy, k === 0 ? 3 : 2, 0, TAU);
ctx.fill();
}
return pen;
}
var prevPen = null;
function stampTrail(pen) {
if (!prevPen) { prevPen = pen; return; }
// globalAlpha 로 코어/확산 두께를 한 경로에서 두 번 칠한다(계수 개수가 흔들려도 잔상이 곱해진다)
trailCtx.globalAlpha = 0.8;
trailCtx.strokeStyle = rgba(C_GLOW, 1);
trailCtx.lineWidth = 1.7;
trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(prevPen.x, prevPen.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.globalAlpha = 0.12;
trailCtx.lineWidth = 4.4;
trailCtx.stroke();
trailCtx.globalAlpha = 1;
prevPen = pen;
}
function decayTrail() {
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = 'destination-out';
trailCtx.fillStyle = 'rgba(0,0,0,0.03)';
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function frame(now) {
if (t0 < 0) t0 = now;
var lt = (now - t0) / 1000;
var u = (lt * 0.5) % 1;
var d = Math.sin(lt * TAU / SWEEP) * 0.8;
var pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
decayTrail();
var pen = draw(u, pulse, d, lt * TAU);
stampTrail(pen);
if (nOut) nOut.textContent = String(SHOW);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
var prev = chainAt(0, 0, SHOW)[SHOW];
for (var i = 1; i <= 200; i++) {
var pen = chainAt(i / 200, 0, SHOW)[SHOW];
stampTrail(pen);
prev = pen;
}
draw(0.5, 0.5, 0, 2.1);
}
window.addEventListener('resize', function () {
fit(); prevPen = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierPhaseCircleMorph — vanilla 판(run/321/demo/07-fourier-phase-circle-morph/index.html) 의 React 포트.
// 위상 모프 — 계수의 두 축(크기·위상) 중 위상만 흔든다.
// ① 별 경로(03 과 동일)의 DFT 계수에서 진폭은 얼리고 위상에만 오프셋 φ_k = d·k 를 더한다.
// ② d 가 0 이면 원래 별. d 가 커질수록 각 원의 시각이 어긋나 무늬가 별→꽃잎→소용돌이로 변한다.
// ③ d 를 사인파로 왕복시키면 무늬가 계속 모프한다 — 크기는 무늬의 "에너지"·위상은 "배치"임을 보여준다.
// 두 번째 시각화: 같은 계수를 오른쪽 위상 원반(각 계수를 단위원 위의 점으로)에 띄운다 —
// 위상이 흔들리면 점들이 원반을 따라 미끄러진다.
// 화면이 사는 장치 — 궤적이 투명 캔버스에 쌓이며 혜성 감쇠, 방사 스윕·스포크가 매 프레임 재계산.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FourierPhaseCircleMorph.module.scss";
export type FourierPhaseCircleMorphProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 경로 샘플 점 수
show?: number; // 체인에 쓰는 계수 수
sweep?: number; // 위상 오프셋 왕복 주기(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
const FIELD_LABEL = "위상 모프 실험판";
const TAU = Math.PI * 2;
function starPath(u: number): Pt {
const seg = u * 10;
const i = Math.floor(seg);
const f = seg - i;
const a1 = i / 10 * TAU - Math.PI / 2;
const a2 = (i + 1) / 10 * TAU - Math.PI / 2;
const r1 = (i % 2 === 0) ? 1 : 0.42;
const r2 = (i % 2 === 0) ? 0.42 : 1;
const x1 = Math.cos(a1) * r1, y1 = Math.sin(a1) * r1;
const x2 = Math.cos(a2) * r2, y2 = Math.sin(a2) * r2;
return { x: x1 + (x2 - x1) * f, y: y1 + (y2 - y1) * f };
}
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M;
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp);
return out;
}
export default function FourierPhaseCircleMorph({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
n = 256,
show = 16,
sweep = 2.4
}: FourierPhaseCircleMorphProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nRef = useRef(n) as Box<number>;
const showRef = useRef(show) as Box<number>;
const sweepRef = useRef(sweep) as Box<number>;
const pathRef = useRef<Pt[]>([]) as Box<Pt[]>;
const coefRef = useRef<Coef[]>([]) as Box<Coef[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [cnt, setCnt] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const trailCv = document.createElement("canvas");
trailCv.width = W * dpr; trailCv.height = H * dpr;
const trailCtx = trailCv.getContext("2d");
if (trailCtx) trailCtx.scale(dpr, dpr);
trailCvRef.current = trailCv;
trailCtxRef.current = trailCtx;
if (!pathRef.current.length) {
const PATH: Pt[] = [];
for (let i = 0; i < nRef.current; i++) PATH.push(starPath(i / nRef.current));
pathRef.current = PATH;
coefRef.current = dft(PATH);
}
}
// 위상 오프셋 체인 — d 만큼 각 계수 위상을 민다
function chainAt(u: number, d: number, showCount: number): Pt[] {
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
const base = Math.min(W, H) * 0.36;
const cx = W * 0.32, cy = H * 0.5;
const pts: Pt[] = [{ x: cx, y: cy }];
for (let i = 0; i < showCount && i < COEF.length; i++) {
const c = COEF[i];
const ph = Math.atan2(c.im, c.re) + c.freq * d;
const ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
// 왼쪽 체인 자리 스윕
const cx = W * 0.32, cy = H * 0.5;
const maxR = Math.min(W, H) * 0.52;
const sweepA = t * 0.8;
for (let b = 0; b < 16; b++) {
ctx.fillStyle = rgba(col.glow, 0.1 * (1 - b / 16));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweepA - (b + 1) * 0.06, sweepA - b * 0.06);
ctx.closePath();
ctx.fill();
}
const spin = t * 0.3;
for (let i = 0; i < 14; i++) {
const a = spin + (i / 14) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.1 : 0.06);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
// 오른쪽 위상 원반 자리 — 반대 방향 스윕(계단 밴드)
const px = W * 0.78, py = H * 0.5, prad = Math.min(W, H) * 0.42;
const sweep2 = -t * 0.95;
for (let b2 = 0; b2 < 12; b2++) {
ctx.fillStyle = rgba(col.glow, 0.1 * (1 - b2 / 12));
ctx.beginPath();
ctx.moveTo(px, py);
ctx.arc(px, py, prad, sweep2 - (b2 + 1) * 0.07, sweep2 - b2 * 0.07);
ctx.closePath();
ctx.fill();
}
// 하단 가로 스캔 밴드 — 전체 폭을 흐른다(계단 12단)
const bandY = H - 18 - ((t * 22) % (H - 24));
for (let sb = 0; sb < 12; sb++) {
ctx.fillStyle = rgba(col.glow, 0.08 * (1 - sb / 12));
ctx.fillRect(0, bandY + sb * 5, W, 5);
}
}
function draw(u: number, pulse: number, d: number, gt: number): Pt {
const ctx = ctxRef.current;
const trailCv = trailCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
if (!ctx || !trailCv || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, gt);
ctx.drawImage(trailCv, 0, 0, W, H);
const S = showRef.current;
const pts = chainAt(u, d, S);
const base = Math.min(W, H) * 0.36;
for (let i = 0; i < pts.length - 1; i++) {
const r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.09 + 0.01 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(col.glow, 0.46);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
const pen = pts[pts.length - 1];
const pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
// ── 오른쪽 위상 원반 — 상위 계수의 위상을 단위원 위 점으로 ──
const px = W * 0.78, py = H * 0.5, prad = Math.min(W, H) * 0.3;
ctx.strokeStyle = rgba(col.glow, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(px, py, prad, 0, TAU);
ctx.stroke();
ctx.strokeStyle = rgba(col.glow, 0.1);
ctx.beginPath();
ctx.moveTo(px - prad - 6, py); ctx.lineTo(px + prad + 6, py);
ctx.moveTo(px, py - prad - 6); ctx.lineTo(px, py + prad + 6);
ctx.stroke();
// 계수별 위상 각도(오프셋 적용 뒤)에 점 — 주파수 비례 반지름
for (let k = 0; k < 12 && k < COEF.length; k++) {
const c = COEF[k];
const ph2 = Math.atan2(c.im, c.re) + c.freq * d;
const rr = prad * (1 - k / 14);
const qx = px + Math.cos(ph2) * rr, qy = py + Math.sin(ph2) * rr;
ctx.fillStyle = rgba(k === 0 ? col.hot : col.glow, 0.85);
ctx.beginPath();
ctx.arc(qx, qy, k === 0 ? 3 : 2, 0, TAU);
ctx.fill();
}
return pen;
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
function stampTrail(pen: Pt): void {
const trailCtx = trailCtxRef.current;
const col = colRef.current;
const prev = prevPenRef.current;
if (!trailCtx || !col) return;
if (!prev) { prevPenRef.current = pen; return; }
// globalAlpha 로 코어/확산 두께를 한 경로에서 두 번 칠한다(계수 개수가 흔들려도 잔상이 곱해진다)
trailCtx.globalAlpha = 0.8;
trailCtx.strokeStyle = rgba(col.glow, 1);
trailCtx.lineWidth = 1.7;
trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(prev.x, prev.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.globalAlpha = 0.12;
trailCtx.lineWidth = 4.4;
trailCtx.stroke();
trailCtx.globalAlpha = 1;
prevPenRef.current = pen;
}
function decayTrail(): void {
const trailCtx = trailCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!trailCtx) return;
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = "destination-out";
trailCtx.fillStyle = "rgba(0,0,0,0.03)";
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = "source-over";
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const lt = (now - t0) / 1000;
const u = (lt * 0.5) % 1;
const d = Math.sin(lt * TAU / sweepRef.current) * 0.8;
const pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
decayTrail();
const pen = draw(u, pulse, d, lt * TAU);
stampTrail(pen);
setCnt(String(showRef.current));
rafRef.current = requestAnimationFrame(frame);
}
function stillFrame(): void {
const S = showRef.current;
let prev = chainAt(0, 0, S)[S];
for (let i = 1; i <= 200; i++) {
const pen = chainAt(i / 200, 0, S)[S];
stampTrail(pen);
prev = pen;
}
draw(0.5, 0.5, 0, 2.1);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nRef.current = n;
showRef.current = show;
sweepRef.current = sweep;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setCnt(String(show));
function onResize(): void {
fit(); prevPenRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, show, sweep]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>계수 크기는 그대로 두고 위상에만 오프셋을 주면 무늬가 별에서 꽃잎으로, 꽃잎에서 소용돌이로 연속 변형되는데, 크기와 위상이 그림에서 각각 무엇을 담당하는지 보여주는 실험입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b> 위상 오프셋 진동 · 크기 고정</span>
<span className={styles.metaDim}>canvas · phase offset · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · 位相モーフ — 係数はそのまま、位相だけ回すと模様が変身する — 計測 累積面積 40.69% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
位相だけを掛けて動かす実験
chainAtのphにc.freq * dを1行足すだけで、大きさは「どれだけ」を、位相は「どこから」を担うという役割分担がそのまま見えます。位相を元に戻すと軌跡は必ず星へ戻るので、絵の正体が係数の大きさ側にあることを確認できます。
function chainAt(u, d, show) {
var base = Math.min(W, H) * 0.36;
var cx = W * 0.32, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
for (var i = 0; i < show && i < COEF.length; i++) {
var c = COEF[i];
var ph = Math.atan2(c.im, c.re) + c.freq * d;
var ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
08倍音スライダー — 円の数を減らすと絵がどう滲むか
ハート曲線(04と同一)の係数を大きさ順に用意し、円の数を1から64までのスライダーで動かすと、ハートが水滴のにじみからくっきりしたハートへ蘇ります。自動デモはSWEEP=3.2秒周期の三角波で数を往復させ、スライダーを直接つかむと自動モードが切れて人の値で動きます。フレーム最大面積12.36%・強度平均31.8で、24フレーム中23フレームが動き続けます。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>使う円の数をスライダーで1から全体まで動かすと、ハートが水滴からくっきりしたハートへ蘇ります。JPEGや音楽圧縮と同じ、どこまで減らしても本質が残るかの実験です</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="倍音スライダー 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<p class="fe__panel">
<label class="fe__label" for="feSlider">円の数</label>
<input class="fe__slider" id="feSlider" type="range" min="1" max="64" step="1" value="1" aria-label="円の数">
</p>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">1</b><span id="feUnit">使用中・ハート再合成</span></span>
<span class="fe__meta fe__meta--dim">canvas · truncated series · slider</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 배음 슬라이더 실험(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(하트 DFT 계수 체인 → 원 개수 변화)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
// 좁은 폭(320px) — 슬라이더 패널 한 줄까지 넣고도 무대 안에 들어오게 여백을 당긴다
@media (max-width: 359px) {
.fe { gap: $sp-1; }
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 140px; // 슬라이더 패널 한 줄이 들어와 필드는 낮게(480x300·320px 넘침 방지)
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
// 슬라이더 — 원 개수를 1~전체로 움직인다. 라벨 글자는 밝은 회청(ink 면 위 대비 확보)
.fe__panel {
display: flex; align-items: center; gap: $sp-2; margin: 0;
}
.fe__label { font-size: 10px; font-weight: 700; color: $fe-text; white-space: nowrap; }
.fe__panel input[type=range] {
flex: 1 1 auto; -webkit-appearance: none; appearance: none;
height: 4px; border-radius: $r-pill; margin: 0;
background: rgba($fe-glow, .24);
}
.fe__panel input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; border-radius: 50%;
background: $fe-hot; border: 0; cursor: pointer;
}
.fe__panel input[type=range]::-moz-range-thumb {
width: 14px; height: 14px; border-radius: 50%;
background: $fe-hot; border: 0; cursor: pointer;
}
.fe__panel input[type=range]:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 84px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
@media (max-width: 359px) {
.fe {
gap: 4px;
}
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 140px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__panel {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.fe__label {
font-size: 10px;
font-weight: 700;
color: #d6d9e6;
white-space: nowrap;
}
.fe__panel input[type=range] {
flex: 1 1 auto;
-webkit-appearance: none;
appearance: none;
height: 4px;
border-radius: 999px;
margin: 0;
background: rgba(183, 200, 255, 0.24);
}
.fe__panel input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffe9c4;
border: 0;
cursor: pointer;
}
.fe__panel input[type=range]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #ffe9c4;
border: 0;
cursor: pointer;
}
.fe__panel input[type=range]:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 84px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 배음 슬라이더 실험(08). 몇 개의 원만 남겨도 본떼가 남는가 — 계수 절약(압축)의 실험판.
// ① 하트 곡선(04 와 동일)의 DFT 계수를 크기 순으로 준비한다.
// ② 슬라이더(1~64)가 체인에 얹을 계수 개수를 정한다. 1개면 원 하나 — 물방울 한 바퀴.
// ③ 늘릴수록 디테일이 되살아나 오목한 하트가 완성된다 — JPEG·MP3 의 "버려도 되는 계수"와 같은 원리.
// 자동 모드(.is-demo)에서는 슬라이더 값이 스스로 1→64→1 로 왕복해 렌더·측정이 가능하다.
// 사람이 직접 잡으면 자동 왕복을 멈춘다(pointerdown).
//
// 화면이 사는 장치 — 077 기준:
// · 원 개수가 매 순간 변하므로 체인·궤적 전체가 매 프레임 다시 계산된다.
// · 밑바탕 방사 스윕·스포크가 매 프레임 재계산된다. 궤적은 투명 캔버스에 쌓이며 혜성 감쇠.
(function () {
// ── 변형 상수 블록 ──
var N = 256; // 경로 샘플 수
var MAXSHOW = 64; // 슬라이더 최대(체인에 얹을 계수 수)
var SWEEP = 3.2; // 자동 왕복 주기(초)
// ──────────────────
var TAU = Math.PI * 2;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var slider = document.getElementById('feSlider');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var trailCv, trailCtx;
function heartPath(u) {
var t = u * TAU;
var x = 16 * Math.pow(Math.sin(t), 3);
var y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
return { x: x / 17, y: y / 17 };
}
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M;
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; });
return out;
}
var PATH = [], COEF = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
trailCv = document.createElement('canvas');
trailCv.width = W * dpr; trailCv.height = H * dpr;
trailCtx = trailCv.getContext('2d');
trailCtx.scale(dpr, dpr);
if (!PATH.length) {
for (var i = 0; i < N; i++) PATH.push(heartPath(i / N));
COEF = dft(PATH);
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function chainAt(u, show) {
var base = Math.min(W, H) * 0.4;
var cx = W * 0.5, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
for (var i = 0; i < show && i < COEF.length; i++) {
var c = COEF[i];
var ph = Math.atan2(c.im, c.re);
var ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
function drawGrid(t) {
var ctx = cv.getContext('2d');
var cx = W * 0.5, cy = H * 0.5;
var maxR = Math.sqrt(W * W + H * H) * 0.5;
var sweep = t * 0.85;
for (var b = 0; b < 18; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.095 * (1 - b / 18));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.055, sweep - b * 0.055);
ctx.closePath();
ctx.fill();
}
var spin = t * 0.28;
for (var i = 0; i < 16; i++) {
var a = spin + (i / 16) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.095 : 0.055);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
}
function draw(u, pulse, show, gt) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(gt);
ctx.drawImage(trailCv, 0, 0, W, H);
var base = Math.min(W, H) * 0.4;
var pts = chainAt(u, show);
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.09 + 0.01 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(C_GLOW, 0.46);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
var pen = pts[pts.length - 1];
var pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(C_HOT, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
var prevPen = null;
function stampTrail(pen) {
if (!prevPen) { prevPen = pen; return; }
trailCtx.strokeStyle = rgba(C_HOT, 0.8);
trailCtx.lineWidth = 1.7;
trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(prevPen.x, prevPen.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(C_HOT, 0.12);
trailCtx.lineWidth = 4.6;
trailCtx.stroke();
prevPen = pen;
}
function decayTrail() {
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = 'destination-out';
trailCtx.fillStyle = 'rgba(0,0,0,0.03)';
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var autoMode = true;
function frame(now) {
if (t0 < 0) t0 = now;
var lt = (now - t0) / 1000;
var u = (lt * 0.55) % 1;
var pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
var show;
if (autoMode) {
// 자동 왕복 — 1→64→1. 사람이 잡으면 멈춘다
var tri = 0.5 - 0.5 * Math.cos(lt * TAU / SWEEP);
show = 1 + Math.round(tri * (MAXSHOW - 1));
if (slider && String(slider.value) !== String(show)) slider.value = String(show);
} else {
show = slider ? parseInt(slider.value, 10) : MAXSHOW;
}
decayTrail();
var pen = draw(u, pulse, show, lt * TAU);
stampTrail(pen);
if (nOut) nOut.textContent = String(show);
raf = requestAnimationFrame(frame);
}
if (slider) {
slider.addEventListener('pointerdown', function () { autoMode = false; });
slider.addEventListener('input', function () {
autoMode = false;
prevPen = null;
if (nOut) nOut.textContent = slider.value;
});
slider.addEventListener('keydown', function () { autoMode = false; });
}
function stillFrame() {
// reduce — 원 8개 궤적을 미리 쌓은 뒤 한 장 고정
var show = 8;
var prev = chainAt(0, show)[show];
for (var i = 1; i <= 200; i++) {
var pen = chainAt(i / 200, show)[show];
stampTrail(pen);
prev = pen;
}
draw(0.5, 0.5, show, 2.1);
}
window.addEventListener('resize', function () {
fit(); prevPen = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierHarmonicSliderLab — vanilla 판(run/321/demo/08-fourier-harmonic-slider-lab/index.html) 의 React 포트.
// 배음 슬라이더 실험 — 몇 개의 원만 남겨도 본떼가 남는가(계수 절약·압축)의 실험판.
// ① 하트 곡선(04 와 동일)의 DFT 계수를 크기 순으로 준비한다.
// ② 슬라이더(1~64)가 체인에 얹을 계수 개수를 정한다. 1개면 원 하나 — 물방울 한 바퀴.
// ③ 늘릴수록 디테일이 되살아나 오목한 하트가 완성된다 — JPEG·MP3 의 "버려도 되는 계수"와 같은 원리.
// 자동 모드에서는 슬라이더 값이 스스로 1→64→1 로 왕복한다. 사람이 직접 잡으면(pointerdown·input)
// 자동 왕복을 멈춘다.
// 화면이 사는 장치 — 원 개수가 매 순간 변하므로 체인·궤적 전체가 매 프레임 다시 계산된다.
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./FourierHarmonicSliderLab.module.scss";
export type FourierHarmonicSliderLabProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 경로 샘플 수
maxShow?: number; // 슬라이더 최대(체인에 얹을 계수 수)
sweep?: number; // 자동 왕복 주기(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
const FIELD_LABEL = "배음 슬라이더 실험판";
const TAU = Math.PI * 2;
function heartPath(u: number): Pt {
const t = u * TAU;
const x = 16 * Math.pow(Math.sin(t), 3);
const y = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
return { x: x / 17, y: y / 17 };
}
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M;
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp);
return out;
}
export default function FourierHarmonicSliderLab({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
n = 256,
maxShow = 64,
sweep = 3.2
}: FourierHarmonicSliderLabProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const sliderRef = useRef<HTMLInputElement | null>(null);
const nRef = useRef(n) as Box<number>;
const maxShowRef = useRef(maxShow) as Box<number>;
const sweepRef = useRef(sweep) as Box<number>;
const pathRef = useRef<Pt[]>([]) as Box<Pt[]>;
const coefRef = useRef<Coef[]>([]) as Box<Coef[]>;
const autoModeRef = useRef(true) as Box<boolean>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [cnt, setCnt] = useState("1");
const [sliderVal, setSliderVal] = useState(1);
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const trailCv = document.createElement("canvas");
trailCv.width = W * dpr; trailCv.height = H * dpr;
const trailCtx = trailCv.getContext("2d");
if (trailCtx) trailCtx.scale(dpr, dpr);
trailCvRef.current = trailCv;
trailCtxRef.current = trailCtx;
if (!pathRef.current.length) {
const PATH: Pt[] = [];
for (let i = 0; i < nRef.current; i++) PATH.push(heartPath(i / nRef.current));
pathRef.current = PATH;
coefRef.current = dft(PATH);
}
}
function chainAt(u: number, show: number): Pt[] {
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
const base = Math.min(W, H) * 0.4;
const cx = W * 0.5, cy = H * 0.5;
const pts: Pt[] = [{ x: cx, y: cy }];
for (let i = 0; i < show && i < COEF.length; i++) {
const c = COEF[i];
const ph = Math.atan2(c.im, c.re);
const ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.5, cy = H * 0.5;
const maxR = Math.sqrt(W * W + H * H) * 0.5;
const sweepA = t * 0.85;
for (let b = 0; b < 18; b++) {
ctx.fillStyle = rgba(col.glow, 0.095 * (1 - b / 18));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweepA - (b + 1) * 0.055, sweepA - b * 0.055);
ctx.closePath();
ctx.fill();
}
const spin = t * 0.28;
for (let i = 0; i < 16; i++) {
const a = spin + (i / 16) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.095 : 0.055);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 12, cy + Math.sin(a) * 12);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
}
function draw(u: number, pulse: number, show: number, gt: number): Pt {
const ctx = ctxRef.current;
const trailCv = trailCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
if (!ctx || !trailCv || !col) return { x: 0, y: 0 };
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, gt);
ctx.drawImage(trailCv, 0, 0, W, H);
const base = Math.min(W, H) * 0.4;
const pts = chainAt(u, show);
for (let i = 0; i < pts.length - 1; i++) {
const r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.09 + 0.01 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(col.glow, 0.46);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
const pen = pts[pts.length - 1];
const pr = 2.4 + pulse * 2.2;
ctx.fillStyle = rgba(col.hot, 0.3);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
return pen;
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
function stampTrail(pen: Pt): void {
const trailCtx = trailCtxRef.current;
const col = colRef.current;
const prev = prevPenRef.current;
if (!trailCtx || !col) return;
if (!prev) { prevPenRef.current = pen; return; }
trailCtx.strokeStyle = rgba(col.hot, 0.8);
trailCtx.lineWidth = 1.7;
trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(prev.x, prev.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(col.hot, 0.12);
trailCtx.lineWidth = 4.6;
trailCtx.stroke();
prevPenRef.current = pen;
}
function decayTrail(): void {
const trailCtx = trailCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!trailCtx) return;
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = "destination-out";
trailCtx.fillStyle = "rgba(0,0,0,0.03)";
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = "source-over";
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const lt = (now - t0) / 1000;
const u = (lt * 0.55) % 1;
const pulse = 0.5 + 0.5 * Math.sin(lt * 8.0);
let show: number;
if (autoModeRef.current) {
// 자동 왕복 — 1→64→1. 사람이 잡으면 멈춘다
const tri = 0.5 - 0.5 * Math.cos(lt * TAU / sweepRef.current);
show = 1 + Math.round(tri * (maxShowRef.current - 1));
const slider = sliderRef.current;
if (slider && slider.value !== String(show)) {
slider.value = String(show);
setSliderVal(show);
}
} else {
const slider = sliderRef.current;
show = slider ? parseInt(slider.value, 10) : maxShowRef.current;
}
decayTrail();
const pen = draw(u, pulse, show, lt * TAU);
stampTrail(pen);
setCnt(String(show));
rafRef.current = requestAnimationFrame(frame);
}
// 사람이 잡으면 자동 왕복을 멈춘다
function onSliderPointerDown(_e: ReactPointerEvent<HTMLInputElement>): void {
autoModeRef.current = false;
}
function onSliderInput(e: ChangeEvent<HTMLInputElement>): void {
autoModeRef.current = false;
prevPenRef.current = null;
const v = parseInt(e.target.value, 10);
setSliderVal(v);
setCnt(String(v));
}
function stillFrame(): void {
// reduce — 원 8개 궤적을 미리 쌓은 뒤 한 장 고정
const show = 8;
let prev = chainAt(0, show)[show];
for (let i = 1; i <= 200; i++) {
const pen = chainAt(i / 200, show)[show];
stampTrail(pen);
prev = pen;
}
draw(0.5, 0.5, show, 2.1);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nRef.current = n;
maxShowRef.current = maxShow;
sweepRef.current = sweep;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
function onResize(): void {
fit(); prevPenRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, maxShow, sweep]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>사용할 원 개수를 슬라이더로 1부터 전체까지 움직이면 하트가 물방울에서 점점 또렷한 하트로 되살아나는데, 이것이 JPEG 음악 압축까지 같은 원리의 얼마나 줄여도 본떼가 남는가 실험입니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<p className={styles.panel}>
<label className={styles.label} htmlFor="feSlider">원 개수</label>
<input
className={styles.slider}
id="feSlider"
ref={sliderRef}
type="range"
min={1}
max={maxShow}
step={1}
value={sliderVal}
aria-label="원 개수"
onPointerDown={onSliderPointerDown}
onChange={onSliderInput}
/>
</p>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b>개 사용 중 · 하트 재합성</span>
<span className={styles.metaDim}>canvas · truncated series · slider</span>
</div>
</div>
</main>
);
}08 · 倍音スライダー — 円の数を減らすと絵がどう滲むか — 計測 累積面積 41.044% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
三角波で円の数を往復させる
autoModeの間はコサインで作った三角波が1→64→1の往復を計算し、スライダーの見た目も同じ値へ追従します。pointerdown・input・keydownのどれかが来た時点で自動を切るので、デモと実験が1本のつまみで切り替わります。
var show;
if (autoMode) {
// 自動往復 — 1→64→1。人がつかむと止まる
var tri = 0.5 - 0.5 * Math.cos(lt * TAU / SWEEP);
show = 1 + Math.round(tri * (MAXSHOW - 1));
if (slider && String(slider.value) !== String(show)) slider.value = String(show);
} else {
show = slider ? parseInt(slider.value, 10) : MAXSHOW;
}
09ロゴトレースシーン — 暗い舞台での一筆ロゴ公開
一筆書きの「G」ロゴ(300度の円弧と内側へ折れて横棒)の輪郭を03・04と同じ変換と再合成で描き直す、この回の締めの演出です。暗い舞台に半径もアルファも息づく中央照明を敷き、トレイルの減衰はα=0.012と9種で最も遅くして、居座る残像自体をコンセプトにします。円が止まった後も照明が息づき続けるので毎フレームが動き、累積面積は9種で最大の47.314%です。

<main class="stage">
<div class="fe is-demo" id="feRoot">
<p class="fe__hint">
<span>黒い背景に中央照明を敷き、エピサイクル連鎖にロゴの輪郭を一筆で描き直させると、円の回転が止まっても残像トレイルが残る、ブランド公開に使える一場面になります</span>
</p>
<div class="fe__field" id="feField" role="application" aria-label="ロゴトレースシーン 実験版" tabindex="0">
<canvas class="fe__cv" id="feCv"></canvas>
</div>
<div class="fe__bar">
<span class="fe__meta"><b class="fe__val" id="feN">0</b><span id="feUnit"> 一筆・残像保持ライティング</span></span>
<span class="fe__meta fe__meta--dim">canvas · brand reveal · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 에피사이클 체인(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(복소 계수 체인 회전 → 펜 궤적 누적)가 만든다.
// 무대는 심야 관측 팔레트. 파스텔 주색을 --fe-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 심야 관측 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$fe-stage: #141824; // 심야 그래프지 — 저채도 짙은 남색 (파스텔 검사 무관: 저채도·저명도)
@include stage($fe-stage);
$fe-ink: #10141f; // 필드 바닥 — 무대보다 한 톤 어두운 남흑
$fe-glow: #b7c8ff; // 궤적 주색 — 파스텔 페리윙클(고명도라 vivid 아님)
$fe-hot: #ffe9c4; // 펜 절정 — 파스텔 크림
$fe-text: #d6d9e6; // 라벨 글자 — 밝은 회청
.fe {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --fe-* 를 받아 쓴다
--fe-ink: #{$fe-ink};
--fe-glow: #{$fe-glow};
--fe-hot: #{$fe-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.fe__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: $fe-text;
}
.fe__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $fe-ink;
box-shadow: inset 0 0 0 1px rgba($fe-text, .18);
}
.fe__field:focus-visible { outline: 2px solid $fe-glow; outline-offset: 2px; }
.fe__cv { display: block; width: 100%; height: 100%; }
.fe__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.fe__meta { font-size: 10px; font-weight: 500; color: rgba($fe-text, .72); }
.fe__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.fe { gap: $sp-1; }
.fe__field { height: 100px; }
.fe__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 궤적이 다 쌓인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #141824;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.fe {
--fe-ink: #10141f;
--fe-glow: #b7c8ff;
--fe-hot: #ffe9c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.fe__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: #d6d9e6;
}
.fe__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #10141f;
box-shadow: inset 0 0 0 1px rgba(214, 217, 230, 0.18);
}
.fe__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.fe__cv {
display: block;
width: 100%;
height: 100%;
}
.fe__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.fe__meta {
font-size: 10px;
font-weight: 500;
color: rgba(214, 217, 230, 0.72);
}
.fe__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.fe {
gap: 4px;
}
.fe__field {
height: 100px;
}
.fe__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.fe.is-still .fe__field {
cursor: default !important;
}
}// 로고 트레이스 씬(09). 편의 마무리 — DFT 재합성을 "브랜드 공개" 장면으로 연출한다.
// ① 로고 윤곽: G 의 원호 + 가로획으로 이루어진 한 획 경로를 매개변수 점열로 정의한다.
// ② 03·04 와 같은 DFT → 체인 재합성으로 붓끝이 윤곽을 되그린다.
// ③ 연출 — 중앙 스포트라이트(계단 밴드 동심원)가 천천히 돌고, 붓끝에 하이라이트 절정 점.
// 한 바퀴를 그린 뒤엔 체인이 잠시 사라지고 잔상 트레일만 남는다(브랜드 공개의 정적 순간).
//
// 화면이 사는 장치 — 077 기준:
// · 스포트라이트 동심원·스포크가 매 프레임 재계산되어 화면 전면이 살아 있다.
// · 트레일은 투명 캔버스에 쌓이고 아주 느리게 감쇠 — 잔상 유지가 컨셉이다.
// · 머무는 단계에서도 스포트라이트가 계속 숨쉬므로 23/23 프레임이 움직인다.
(function () {
// ── 변형 상수 블록 ──
var N = 256; // 경로 샘플 수
var SHOW = 36; // 체인에 그리는 円の数
var HOLD = 0.35; // 완성 후 잔상만 머무는 비율
// ──────────────────
var TAU = Math.PI * 2;
var root = document.getElementById('feRoot');
var field = document.getElementById('feField');
var cv = document.getElementById('feCv');
var nOut = document.getElementById('feN');
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--fe-ink');
var C_GLOW = tokRGB('--fe-glow');
var C_HOT = tokRGB('--fe-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var raf = 0, t0 = -1;
var W = 0, H = 0, dpr = 1;
var trailCv, trailCtx;
// ── ① 로고 윤곽 — "G" 한 획. 원호 300도 + 안쪽으로 꺾여 가로획 ──
function logoPath(u) {
// 0~0.72: 원호(각도 -60도에서 240도 방향으로), 0.72~1: 가로획
var ARC = 0.72;
var R = 1;
if (u < ARC) {
var ang = (-Math.PI / 6) + (u / ARC) * (TAU * 300 / 360) * -1;
return { x: Math.cos(ang) * R * 0.62, y: Math.sin(ang) * R * 0.62 };
}
var f = (u - ARC) / (1 - ARC);
var sx = Math.cos(-Math.PI / 6) * 0.62, sy = Math.sin(-Math.PI / 6) * 0.62;
var ex = 0.42, ey = 0.02;
var cx2 = sx + (ex - sx) * f, cy2 = sy + (ey - sy) * f;
return { x: cx2, y: cy2 };
}
function dft(path) {
var M = path.length;
var out = [];
for (var k = 0; k < M; k++) {
var freq = (k <= M / 2) ? k : k - M;
var re = 0, im = 0;
for (var n = 0; n < M; n++) {
var a = -TAU * k * n / M;
var c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort(function (a, b) { return b.amp - a.amp; });
return out;
}
var PATH = [], COEF = [];
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = '100%'; cv.style.height = '100%';
trailCv = document.createElement('canvas');
trailCv.width = W * dpr; trailCv.height = H * dpr;
trailCtx = trailCv.getContext('2d');
trailCtx.scale(dpr, dpr);
if (!PATH.length) {
for (var i = 0; i < N; i++) PATH.push(logoPath(i / N));
COEF = dft(PATH);
}
}
function rgba(c, a) { return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a + ')'; }
function chainAt(u, show) {
var base = Math.min(W, H) * 0.4;
var cx = W * 0.5, cy = H * 0.5;
var pts = [{ x: cx, y: cy }];
for (var i = 0; i < show && i < COEF.length; i++) {
var c = COEF[i];
var ph = Math.atan2(c.im, c.re);
var ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
// 중앙 스포트라이트 — 레이더 스윕(계단 밴드) + createRadialGradient 조명 + 동심원 + 스포크
function drawGrid(t) {
var ctx = cv.getContext('2d');
var cx = W * 0.5, cy = H * 0.5;
var maxR = Math.sqrt(W * W + H * H) * 0.5;
// 스포트라이트 조명 — 중앙이 밝고 가장자리로 어두워지는 방사 그라디언트(브랜드 공개 무대)
var spot = ctx.createRadialGradient(cx, cy, Math.min(W, H) * 0.08, cx, cy, maxR);
spot.addColorStop(0, rgba(C_GLOW, 0.1));
spot.addColorStop(0.45, rgba(C_GLOW, 0.03));
spot.addColorStop(1, rgba(C_INK, 0));
ctx.fillStyle = spot;
ctx.fillRect(0, 0, W, H);
// 레이더 스윕 — 스치는 부채꼴이 뒤로 갈수록 옅어지는 계단 밴드 20단
var sweep = t * 0.9;
for (var b = 0; b < 20; b++) {
ctx.fillStyle = rgba(C_GLOW, 0.1 * (1 - b / 20));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.05, sweep - b * 0.05);
ctx.closePath();
ctx.fill();
}
// 동심원 8 — 반지름이 숨쉬고 알파도 계단으로 감쇠
for (var ring = 1; ring <= 8; ring++) {
var rr = (maxR * 0.16) * ring * (1 + 0.02 * Math.sin(t * 1.1 + ring * 1.3));
ctx.strokeStyle = rgba(C_GLOW, 0.12 * (1 - ring / 9));
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, TAU);
ctx.stroke();
}
// 스포크 18 — 천천히 돈다
var spin = t * 0.28;
for (var i = 0; i < 18; i++) {
var a = spin + (i / 18) * TAU;
ctx.strokeStyle = rgba(C_GLOW, i % 2 === 0 ? 0.1 : 0.06);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 14, cy + Math.sin(a) * 14);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
}
function draw(u, pulse, show, gt, showChain) {
var ctx = cv.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
drawGrid(gt);
ctx.drawImage(trailCv, 0, 0, W, H);
var base = Math.min(W, H) * 0.4;
var pen = null;
if (showChain) {
var pts = chainAt(u, show);
for (var i = 0; i < pts.length - 1; i++) {
var r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(C_GLOW, 0.09 + 0.008 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(C_GLOW, 0.5);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
pen = pts[pts.length - 1];
var pr = 2.6 + pulse * 2.4;
ctx.fillStyle = rgba(C_HOT, 0.32);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(C_HOT, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
}
return pen;
}
var prevPen = null;
function stampTrail(pen) {
if (!prevPen) { prevPen = pen; return; }
trailCtx.strokeStyle = rgba(C_HOT, 0.92);
trailCtx.lineWidth = 2.1;
trailCtx.lineCap = 'round';
trailCtx.beginPath();
trailCtx.moveTo(prevPen.x, prevPen.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(C_HOT, 0.14);
trailCtx.lineWidth = 5.4;
trailCtx.stroke();
prevPen = pen;
}
// 잔상 유지 — 감쇠가 아주 느리다(컨셉: 머무는 잔상)
function decayTrail() {
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = 'destination-out';
trailCtx.fillStyle = 'rgba(0,0,0,0.012)';
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = 'source-over';
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
var PHASE_DRAW = 1 - HOLD, wasHold = false;
function frame(now) {
if (t0 < 0) t0 = now;
var lt = ((now - t0) / 1000) * 0.5;
var u = lt % 1;
var pulse = 0.5 + 0.5 * Math.sin(lt * 7.0);
var inHold = u >= PHASE_DRAW;
if (wasHold && !inHold) prevPen = null;
wasHold = inHold;
decayTrail();
if (!inHold) {
var pen = draw(u / PHASE_DRAW, pulse, SHOW, lt * TAU, true);
stampTrail(pen);
} else {
// 머무는 단계 — 체인은 물러나고 잔상만
draw(1, pulse, SHOW, lt * TAU, false);
}
if (nOut) nOut.textContent = String(SHOW);
raf = requestAnimationFrame(frame);
}
function stillFrame() {
var prev = chainAt(0, SHOW)[SHOW];
for (var i = 1; i <= 200; i++) {
var pen = chainAt(i / 200, SHOW)[SHOW];
stampTrail(pen);
prev = pen;
}
draw(1, 0.5, SHOW, 2.1, false);
}
window.addEventListener('resize', function () {
fit(); prevPen = null;
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FourierLogoTraceScene — vanilla 판(run/321/demo/09-fourier-logo-trace-scene/index.html) 의 React 포트.
// 로고 트레이스 씬 — 편의 마무리. DFT 재합성을 "브랜드 공개" 장면으로 연출한다.
// ① 로고 윤곽: G 의 원호 + 가로획으로 이루어진 한 획 경로를 매개변수 점열로 정의한다.
// ② 03·04 와 같은 DFT → 체인 재합성으로 붓끝이 윤곽을 되그린다.
// ③ 연출 — 중앙 스포트라이트(계단 밴드 동심원 + 방사 그라디언트 조명)가 천천히 돌고,
// 붓끝에 하이라이트 절정 점. 한 바퀴를 그린 뒤엔 체인이 잠시 사라지고 잔상 트레일만 남는다.
// 화면이 사는 장치 — 스포트라이트 동심원·스포크가 매 프레임 재계산,
// 트레일은 투명 캔버스에 쌓이고 아주 느리게 감쇠(잔상 유지가 컨셉). 머무는 단계에서도 숨쉰다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FourierLogoTraceScene.module.scss";
export type FourierLogoTraceSceneProps = {
duration?: string;
easing?: string;
color?: string;
n?: number; // 경로 샘플 수
show?: number; // 체인에 그리는 원 개수
hold?: number; // 완성 후 잔상만 머무는 비율
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Pt = { x: number; y: number };
type Coef = { freq: number; re: number; im: number; amp: number };
const FIELD_LABEL = "로고 트레이스 씬 실험판";
const TAU = Math.PI * 2;
// ── ① 로고 윤곽 — "G" 한 획. 원호 300도 + 안쪽으로 꺾여 가로획 ──
function logoPath(u: number): Pt {
// 0~0.72: 원호(각도 -60도에서 240도 방향으로), 0.72~1: 가로획
const ARC = 0.72;
const R = 1;
if (u < ARC) {
const ang = (-Math.PI / 6) + (u / ARC) * (TAU * 300 / 360) * -1;
return { x: Math.cos(ang) * R * 0.62, y: Math.sin(ang) * R * 0.62 };
}
const f = (u - ARC) / (1 - ARC);
const sx = Math.cos(-Math.PI / 6) * 0.62, sy = Math.sin(-Math.PI / 6) * 0.62;
const ex = 0.42, ey = 0.02;
return { x: sx + (ex - sx) * f, y: sy + (ey - sy) * f };
}
function dft(path: Pt[]): Coef[] {
const M = path.length;
const out: Coef[] = [];
for (let k = 0; k < M; k++) {
const freq = (k <= M / 2) ? k : k - M;
let re = 0, im = 0;
for (let n = 0; n < M; n++) {
const a = -TAU * k * n / M;
const c = Math.cos(a), s = Math.sin(a);
re += path[n].x * c - path[n].y * s;
im += path[n].x * s + path[n].y * c;
}
re /= M; im /= M;
out.push({ freq: freq, re: re, im: im, amp: Math.sqrt(re * re + im * im) });
}
out.sort((a, b) => b.amp - a.amp);
return out;
}
export default function FourierLogoTraceScene({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b7c8ff",
n = 256,
show = 36,
hold = 0.35
}: FourierLogoTraceSceneProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const trailCvRef = useRef<HTMLCanvasElement | null>(null);
const trailCtxRef = useRef<CanvasRenderingContext2D | null>(null);
const nRef = useRef(n) as Box<number>;
const showRef = useRef(show) as Box<number>;
const holdRef = useRef(hold) as Box<number>;
const pathRef = useRef<Pt[]>([]) as Box<Pt[]>;
const coefRef = useRef<Coef[]>([]) as Box<Coef[]>;
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const dprRef = useRef(1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [cnt, setCnt] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")";
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const dpr = Math.min(2, window.devicePixelRatio || 1);
wRef.current = W; hRef.current = H; dprRef.current = dpr;
cv.width = W * dpr; cv.height = H * dpr;
cv.style.width = "100%"; cv.style.height = "100%";
ctxRef.current = cv.getContext("2d");
const trailCv = document.createElement("canvas");
trailCv.width = W * dpr; trailCv.height = H * dpr;
const trailCtx = trailCv.getContext("2d");
if (trailCtx) trailCtx.scale(dpr, dpr);
trailCvRef.current = trailCv;
trailCtxRef.current = trailCtx;
if (!pathRef.current.length) {
const PATH: Pt[] = [];
for (let i = 0; i < nRef.current; i++) PATH.push(logoPath(i / nRef.current));
pathRef.current = PATH;
coefRef.current = dft(PATH);
}
}
function chainAt(u: number, showCount: number): Pt[] {
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
const base = Math.min(W, H) * 0.4;
const cx = W * 0.5, cy = H * 0.5;
const pts: Pt[] = [{ x: cx, y: cy }];
for (let i = 0; i < showCount && i < COEF.length; i++) {
const c = COEF[i];
const ph = Math.atan2(c.im, c.re);
const ang = c.freq * u * TAU + ph;
pts.push({
x: pts[i].x + Math.cos(ang) * c.amp * base,
y: pts[i].y + Math.sin(ang) * c.amp * base
});
}
return pts;
}
// 중앙 스포트라이트 — 레이더 스윕(계단 밴드) + createRadialGradient 조명 + 동심원 + 스포크
function drawGrid(ctx: CanvasRenderingContext2D, t: number): void {
const W = wRef.current, H = hRef.current;
const col = colRef.current;
if (!col) return;
const cx = W * 0.5, cy = H * 0.5;
const maxR = Math.sqrt(W * W + H * H) * 0.5;
// 스포트라이트 조명 — 중앙이 밝고 가장자리로 어두워지는 방사 그라디언트(브랜드 공개 무대)
const spot = ctx.createRadialGradient(cx, cy, Math.min(W, H) * 0.08, cx, cy, maxR);
spot.addColorStop(0, rgba(col.glow, 0.1));
spot.addColorStop(0.45, rgba(col.glow, 0.03));
spot.addColorStop(1, rgba(col.ink, 0));
ctx.fillStyle = spot;
ctx.fillRect(0, 0, W, H);
// 레이더 스윕 — 스치는 부채꼴이 뒤로 갈수록 옅어지는 계단 밴드 20단
const sweep = t * 0.9;
for (let b = 0; b < 20; b++) {
ctx.fillStyle = rgba(col.glow, 0.1 * (1 - b / 20));
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, maxR, sweep - (b + 1) * 0.05, sweep - b * 0.05);
ctx.closePath();
ctx.fill();
}
// 동심원 8 — 반지름이 숨쉬고 알파도 계단으로 감쇠
for (let ring = 1; ring <= 8; ring++) {
const rr = (maxR * 0.16) * ring * (1 + 0.02 * Math.sin(t * 1.1 + ring * 1.3));
ctx.strokeStyle = rgba(col.glow, 0.12 * (1 - ring / 9));
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, rr, 0, TAU);
ctx.stroke();
}
// 스포크 18 — 천천히 돈다
const spin = t * 0.28;
for (let i = 0; i < 18; i++) {
const a = spin + (i / 18) * TAU;
ctx.strokeStyle = rgba(col.glow, i % 2 === 0 ? 0.1 : 0.06);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(a) * 14, cy + Math.sin(a) * 14);
ctx.lineTo(cx + Math.cos(a) * maxR, cy + Math.sin(a) * maxR);
ctx.stroke();
}
}
function draw(u: number, pulse: number, showCount: number, gt: number, showChain: boolean): Pt | null {
const ctx = ctxRef.current;
const trailCv = trailCvRef.current;
const col = colRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
const COEF = coefRef.current;
if (!ctx || !trailCv || !col) return null;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = "rgb(" + col.ink[0] + "," + col.ink[1] + "," + col.ink[2] + ")";
ctx.fillRect(0, 0, W, H);
drawGrid(ctx, gt);
ctx.drawImage(trailCv, 0, 0, W, H);
const base = Math.min(W, H) * 0.4;
let pen: Pt | null = null;
if (showChain) {
const pts = chainAt(u, showCount);
for (let i = 0; i < pts.length - 1; i++) {
const r = COEF[i].amp * base;
if (r > 2) {
ctx.strokeStyle = rgba(col.glow, 0.09 + 0.008 * i);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(pts[i].x, pts[i].y, r, 0, TAU);
ctx.stroke();
}
if (i < 4 || r > 3) {
ctx.strokeStyle = rgba(col.glow, 0.5);
ctx.beginPath();
ctx.moveTo(pts[i].x, pts[i].y);
ctx.lineTo(pts[i + 1].x, pts[i + 1].y);
ctx.stroke();
}
}
pen = pts[pts.length - 1];
const pr = 2.6 + pulse * 2.4;
ctx.fillStyle = rgba(col.hot, 0.32);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr * 2.3, 0, TAU);
ctx.fill();
ctx.fillStyle = rgba(col.hot, 1);
ctx.beginPath();
ctx.arc(pen.x, pen.y, pr, 0, TAU);
ctx.fill();
}
return pen;
}
const prevPenRef = useRef<Pt | null>(null) as Box<Pt | null>;
function stampTrail(pen: Pt): void {
const trailCtx = trailCtxRef.current;
const col = colRef.current;
const prev = prevPenRef.current;
if (!trailCtx || !col) return;
if (!prev) { prevPenRef.current = pen; return; }
trailCtx.strokeStyle = rgba(col.hot, 0.92);
trailCtx.lineWidth = 2.1;
trailCtx.lineCap = "round";
trailCtx.beginPath();
trailCtx.moveTo(prev.x, prev.y);
trailCtx.lineTo(pen.x, pen.y);
trailCtx.stroke();
trailCtx.strokeStyle = rgba(col.hot, 0.14);
trailCtx.lineWidth = 5.4;
trailCtx.stroke();
prevPenRef.current = pen;
}
// 잔상 유지 — 감쇠가 아주 느리다(컨셉: 머무는 잔상)
function decayTrail(): void {
const trailCtx = trailCtxRef.current;
const dpr = dprRef.current;
const W = wRef.current, H = hRef.current;
if (!trailCtx) return;
trailCtx.setTransform(1, 0, 0, 1, 0, 0);
trailCtx.globalCompositeOperation = "destination-out";
trailCtx.fillStyle = "rgba(0,0,0,0.012)";
trailCtx.fillRect(0, 0, W * dpr, H * dpr);
trailCtx.globalCompositeOperation = "source-over";
trailCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const wasHoldRef = useRef(false) as Box<boolean>;
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const lt = ((now - t0) / 1000) * 0.5;
const u = lt % 1;
const pulse = 0.5 + 0.5 * Math.sin(lt * 7.0);
const PHASE_DRAW = 1 - holdRef.current;
const inHold = u >= PHASE_DRAW;
if (wasHoldRef.current && !inHold) prevPenRef.current = null;
wasHoldRef.current = inHold;
decayTrail();
if (!inHold) {
const pen = draw(u / PHASE_DRAW, pulse, showRef.current, lt * TAU, true);
if (pen) stampTrail(pen);
} else {
// 머무는 단계 — 체인은 물러나고 잔상만
draw(1, pulse, showRef.current, lt * TAU, false);
}
setCnt(String(showRef.current));
rafRef.current = requestAnimationFrame(frame);
}
function stillFrame(): void {
const S = showRef.current;
let prev = chainAt(0, S)[S];
for (let i = 1; i <= 200; i++) {
const pen = chainAt(i / 200, S)[S];
stampTrail(pen);
prev = pen;
}
draw(1, 0.5, S, 2.1, false);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nRef.current = n;
showRef.current = show;
holdRef.current = hold;
colRef.current = {
ink: tokRGB(root, "--fe-ink"),
glow: tokRGB(root, "--fe-glow"),
hot: tokRGB(root, "--fe-hot"),
};
fit();
setCnt(String(show));
function onResize(): void {
fit(); prevPenRef.current = null;
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [n, show, hold]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>검은 배경에 중앙 조명을 깔고 에피사이클 체인이 로고 윤곽을 한 획으로 되그리는 장면을 만들면, 원들의 회전이 멈춰도 잔상 트레일이 남아 브랜드 공개에 쓸 한 장면이 됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{cnt}</b> 한 획 · 잔상 유지 조명</span>
<span className={styles.metaDim}>canvas · brand reveal · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · ロゴトレースシーン — 暗い舞台での一筆ロゴ公開 — 計測 累積面積 47.314% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
円弧と横棒を自分で定義する
ロゴの形も星やハートと同じく自作の関数で与えます。経路の前72%が300度の円弧、残りが内側へ折れる横棒で、DFT以降は03と完全に同じパイプラインです。任意のブランドマークの一筆輪郭をこの1関数で差し替えられるのがこの盤の実用値です。
// ── ① ロゴ輪郭 — "G"の一筆。円弧300度 + 内側へ折れて横棒 ──
function logoPath(u) {
// 0〜0.72: 円弧(-60度から240度の方向へ)、0.72〜1: 横棒
var ARC = 0.72;
var R = 1;
if (u < ARC) {
var ang = (-Math.PI / 6) + (u / ARC) * (TAU * 300 / 360) * -1;
return { x: Math.cos(ang) * R * 0.62, y: Math.sin(ang) * R * 0.62 };
}
var f = (u - ARC) / (1 - ARC);
var sx = Math.cos(-Math.PI / 6) * 0.62, sy = Math.sin(-Math.PI / 6) * 0.62;
var ex = 0.42, ey = 0.02;
var cx2 = sx + (ex - sx) * f, cy2 = sy + (ey - sy) * f;
return { x: cx2, y: cy2 };
}
なぜ円が回るだけで絵になるのか — 複素係数と周波数分解の計算
理由は1つで、線上の点はすでに回転の和だからです。描きたい線の点(x, y)を複素数z = x + iyと見なすと、その点列は周期1の関数z(u)と読めます。この関数は必ず単位円の回転e^(i·2πku)の和に分解できて、各回転の重みがフーリエ係数c_kです。フーリエ変換 描画と聞くと数式の話に見えますが、画面の上で起きているのは半径の足し算の繰り返しです。
点を複素数として積算するだけのDFT
03のdftがやっているのは、各周波数kについて全点に回転を掛けて平均を取る積算です。実部reと虚部imに分けた2行の積算がc_k = (1/N)·Σ z_n·e^(-i·2πkn/N)の実装そのもので、ライブラリも数値計算の仕組みも使わずMath.cosとMath.sinだけが回ります。得られた係数のamp = √(re² + im²)が円の半径、atan2(im, re)が回転の始まりの角度です。
大きさは半径、位相は始まりの角度
再合成では時刻u(0〜1)で各円の角度をfreq·u·2π + 位相と置き、前の円の端から次の中心を置いていきます。連鎖の最後の点はすべての回転の和そのものなので、一周し終えたとき元の絵が蘇ります。係数を大きさ順に並べるのは大きな円から載せることで連鎖が安定し、上位いくつかだけで形の輪郭が決まる——03の上位14個・04の上位60個・08の1〜64個が、この性質を直接確かめられる実験です。フーリエ解析 わかりやすく理解したいなら、式を眺めるよりこの円が載って回る画面を1周見るのが一番の近道です。
回転速度と並べ替え基準を変える3つの変種とreduced-motion
| 名前 | 変更する値 | 変わる感触 |
|---|---|---|
| 使う円の数 | 03のSHOW 14 → 5 | 頂点が丸まって柔らかい星になる |
| 時間の速さ | 01のSPEED 1.0 → 3.0 | トレイルが長く尾を引いてハッチング模様になる |
| ソートの基準 | 03のソート 大きさ順 → 周波数順 | 軌跡は同じでも円の並び方ががらりと変わる |
定数ブロックの1行を書き換えるだけ
3つとも書き換えるのはソース先頭の変数ブロックの1行だけです。円の数は形の解像度、速さは残像の長さ、ソートは連鎖の見た目という、それぞれ別の性格のつまみです。
動きを減らす設定で残る1枚
9盤すべてにprefers-reduced-motion: reduceの分岐が組み込まれており、ループを回さずトレイルを一周分先に打ってから最後の場面を固定し、キャンバスに.is-stillクラスを付けます。静止していても星やハートの完成図が丸ごと見えるので、動きを止めた訪問者に絵だけは届きます。
係数を大きさ順ではなく周波数順に並べると連鎖が崩れる落とし穴
実測で壊れた場面を3つ挙げます。どれも「見た目は合っているのに絵が蘇らない」系の、原因の特定に時間がかかる失敗です。
周波数の折り畳みを省くと閉じた経路が蘇らない
k > N/2の係数をそのまま正の周波数として使うと、後半の円がすべて逆向きに回り、経路の後ろ半分が裏返った別の図形になります。(k <= M / 2) ? k : k - Mの1行が正負両方の周波数をそろえる条件で、これを外すと星の頂点が1個おきに裏側へ飛びました。
大きさと位相を分けられず片方を捨てる
位相を捨てて振幅だけ使うと、円は正しく回ってもペン先が絵を見つけられず、中心の周りの対称な花模様になります。逆に07のように振幅を凍結して位相だけ動かすと形は別物に変わる——大きさが「どれだけ」、位相が「どこから」を担う証拠が、この失敗と実験の対になります。
毎フレームのクリアが残像の感覚を殺す
メインキャンバスを毎フレームclearRectして軌跡を配列から描き直すと、減衰の感覚が死んで彗星の尾が消えます。軌跡は透明なオフスクリーンキャンバスに積み、毎フレームdestination-outのrgba(0,0,0,α)でごくわずかずつ消すのが正解で、累積面積36〜47%・23フレーム遷移という数値条件はこの構成から出ています。
9つのソース一式が入ったzipを展開するパスワードはtjy7w4m5です。定数ブロックの1行を書き換えて壊し方を確かめる材料として使えます。
エピサイクル連鎖を組み込む前によくある質問
エピサイクル とは何で、どうやって絵を描くのですか?
円の上に円を載せて回す仕組みで、各円が1つの周波数を担当します。描きたい線の点を複素数に変換して周波数ごとの重み(係数)を求め、半径=係数の大きさ・速さ=周波数・始まりの角度=位相として円を大きい順に繋ぐと、末端の点の軌跡が元の線をなぞります。
描きたい線はいくつの点で用意すればいいですか?
この回の標準は256点です。少なくても閉じた経路であれば形は蘇りますが、尖った頂点や細かいくぼみを追うための係数が足りず丸まります。多ければ解像度は上がりますが、変換はO(N²)なので05では入力を512点までに抑えて、描きながらの再計算が一瞬で終わる範囲に収めています。
スマホのブラウザでもそのまま動きますか?
動きます。HTMLファイル1つの中のgetContext('2d')とrequestAnimationFrame、配列の四則演算だけで組まれており、WebGLも外部CDNもフォントの読み込みもありません。05の自由描画はポインターイベントで受け取るので、タッチ操作でもそのまま線を引けます。
ロゴ公開と手描きデモ — この9枚を実際どこに置くか
ブランドのイントロやローディング画面なら09 ロゴトレースシーンをそのまま使うのが一番早い方法です。暗い舞台の上で一筆が完成する演出がすでに組んであるので、パスをロゴの経路に差し替えるだけで済みます。訪問者に直接参加してほしいイベントページや芳名帳には05 自分の線を描くを置くと、マウスで引いた落書きがその場でエピサイクルとして蘇る体験になります。
音声・信号処理を説明する教育コンテンツなら06 スペクトラムバーが向いています。係数の大きさがどの円が働いているかを棒で示すので、フーリエ変換という抽象的な概念が目に見えるグラフに変わります。3枚ともファイル1つで完結しているので、追加のフレームワークなしで静的なセクションにそのまま組み込めます。









