푸리에 변환으로 그림 그리기 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기본 에피사이클 체인 — 원 위에 원이 얹혀 맴돌며 점을 찍는 구조
- 02 구형파 합성 — 홀수 배음 원 몇 개로 각진 파형 만들기
- 03 별 그리기 — 계수 순 정렬로 붓끝이 별을 한 획에 그리기
- 04 하트 그리기 — 닫힌 곡선의 계수로 하트를 되살리기
- 05 내 선 그리기 — 마우스로 긋자마자 원이 되살리는 에피사이클
- 06 스펙트럼 막대 — 계수 크기가 알려주는 '어떤 원이 일하는가'
- 07 위상 모프 — 계수는 그대로 두고 위상만 돌리면 무늬가 변신하기
- 08 배음 슬라이더 — 원 개수를 줄이면 그림이 어떻게 흐려지나
- 09 로고 트레이스 씬 — 어두운 무대 위 한 획 로고 공개 장면
데모
아홉 판 모두 HTML 파일 하나로 자립해 브라우저에서 바로 돌아갑니다. 24프레임을 녹화하는 동안 아홉 판 모두 23프레임이 움직였고(움직인프레임 23), 밑바탕의 극격자·레이더 스윕까지 매 프레임 다시 계산해 화면 어느 구석도 정지하지 않습니다. 05번은 마우스로 직접 선을 그을 수 있고 08번은 슬라이더를 잡을 수 있으니 첫 장부터 만져 보시길 권합니다.
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 기본 에피사이클 체인 | 계수 다섯 개로 도는 최소 구조 | 36.176 | 33.1 |
| 02 구형파 합성 | 홀수 배음 원이 각진 파형을 만드는 순간 | 36.880 | 27.4 |
| 03 별 그리기 | 경로 256점의 계수 전부로 별 재현 | 45.551 | 26.4 |
| 04 하트 그리기 | 상위 60개 원의 압축 재합성 | 46.641 | 27.7 |
| 05 내 선 그리기 | 그은 점이 즉시 계수로 바뀌는 재계산 | 42.997 | 23.7 |
| 06 스펙트럼 막대 | 계수 크기와 위상의 별↔낙서 보간 | 36.266 | 33.1 |
| 07 위상 모프 | 크기는 얼리고 위상만 돌리는 실험 | 40.690 | 24.6 |
| 08 배음 슬라이더 | 원 개수를 1부터 64까지 움직이는 압축 체험 | 41.044 | 31.8 |
| 09 로고 트레이스 씬 | 잔상이 가장 느리게 지워지는 마무리 장면 | 47.314 | 24.2 |
09가 누적면적 47.314%로 아홉 판 중 가장 넓게 채웠는데, 트레일 감쇠를 α=0.012로 가장 느리게 잡아 잔상이 오래 머물기 때문입니다. 강도는 01·06이 33.1로 가장 밝았고, 07은 한 프레임이 가장 크게 변했습니다(프레임최대 15.098%).
손으로 정한 계수에서 이산 푸리에 변환까지, 아홉 걸음
순서는 계산을 어디까지 직접 하는가로 정했습니다. 01은 계수를 손으로 다섯 개 정의해 원 체인의 구조만 보여 주고, 02는 푸리에 급수의 고전 장면인 홀수 배음 합성을 각진 파형과 나란히 놓습니다. 03에서 처음으로 실제 경로 256점에 이산 푸리에 변환을 돌려 별을 되살리고, 04는 같은 파이프라인의 하트에서 계수 상위 60개만 남겨 압축의 예고편을 깔고, 05는 그 계산을 포인터 입력에 붙여 그은 즉시 재계산합니다. 06은 계수의 크기와 위상을 막대로 세워 두 그림 사이의 보간을 보여 주고, 07은 진폭을 얼리고 위상에만 오프셋을 더해 무늬가 변신하는 걸 확인합니다. 08은 04의 하트 계수를 슬라이더로 1부터 64까지 줄였다 늘려 압축 실험을 손으로 만지게 하고, 09는 같은 계산을 어두운 무대 위 로고 공개 장면으로 마무리합니다. 별 그리기로 찾아오신 분께는 03이, 원리가 궁금하신 분께는 06·07이 곧장 답이 되는 순서입니다.
항목별 코드
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"> 배음 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
주파수 2·3·5·7·9, 반지름 0.34·0.18·0.12·0.07·0.05의 계수 다섯 개를 손으로 정의해 원들을 차례로 이어 붙이면 맨 끝점이 꽃잎 같은 대칭 무늬를 그리며 도는데, 이것이 재합성 구조의 최소 단위로서 이후 모든 판의 원형입니다. 밑바탕엔 스포크 16개와 숨쉬는 동심원 4개의 극격자가 매 프레임 다시 그려지고 측정은 누적면적 36.176%·강도평균 33.1입니다.
계수 다섯 개에서 체인 좌표까지
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 }
];
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구형파 합성 — 홀수 배음 원 몇 개로 각진 파형 만들기

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
각진 파형은 홀수 배음만 남긴 급수로 분해되고 각 항은 반지름이 1/k인 원이 k 배속으로 도는 것과 같은데, 이 판은 그 원 체인을 왼쪽에 세로로 쌓고 맨 끝점의 높이를 오른쪽 파형 영역에 시간 축으로 펼쳐 쌓습니다. 부드러운 원운동의 합이 뾰족한 계단이 되는 순간이 매 프레임 그려지고 측정은 누적면적 36.880%·프레임최대 9.004%입니다.
세로 스택의 끝점 높이가 파형이 된다
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별 그리기 — 계수 순 정렬로 붓끝이 별을 한 획에 그리기

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
다섯 포인트 별의 닫힌 경로를 256점으로 샘플링해 이산 푸리에 변환을 돌리면 계수 256개가 나오는데, 크기 순으로 정렬해 원 체인을 세우면 붓끝이 별의 윤곽을 한 바퀴 돌아 되살립니다. 체인에 얹는 계수는 큰 것부터 14개(SHOW=14)라 가장자리가 아주 미세하게 부드러워지지만 상위 14개만으로 별의 형태는 사실상 완성되며, 밑바탕의 레이더 스윕은 22단 계단 알파의 부채꼴로 그라디언트 없이 옅어지는 데다 누적면적 45.551%까지 채워 별이 화면의 절반가량을 삼킵니다.
경로 256점을 복소 계수로 바꾸는 변환
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하트 그리기 — 닫힌 곡선의 계수로 하트를 되살리기

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
하트 매개변수 곡선을 같은 256점 변환으로 보내 상위 60개 계수만 그리면, 체인이 꼬리의 오목한 꺾임에서 시작해 하트를 정확히 되짚으며 계수를 버려도 본떼가 남는다는 압축의 성질을 미리 봅니다. 누적면적 46.641%로 03보다 조금 더 넓게 채웠고 프레임최대는 5.647%입니다.
계수에서 체인 좌표를 다시 더하는 재합성
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;
}
05내 선 그리기 — 마우스로 긋자마자 원이 되살리는 에피사이클

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
캔버스에 pointerdown~move로 선을 그으면 점들이 중심·폭으로 정규화된 뒤 즉시 복소 계수로 바뀌어, 방금 손으로 그은 서명·낙서를 에피사이클이 한 획씩 다시 그립니다. 계산은 O(N²)이지만 점을 512개로 제한해 순식간에 끝나고, 입력이 없을 땐 에피사이클이 물결 서명 예시를 반복해서 되그리며 옅은 잔상을 남기고, 누적면적은 42.997%입니다.
그은 점의 정규화와 즉시 재계산
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 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스펙트럼 막대 — 계수 크기가 알려주는 '어떤 원이 일하는가'

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
별과 여러 주파수가 비슷한 크기로 섞인 물결 낙서, 두 그림의 계수를 주파수 슬롯(±12)에 막대로 세우고 진폭·위상을 2.6초 주기로 왕복 보간하면 별에서 낙서로 그림의 지문이 연속 변형됩니다. 막대 영역이 화면의 좁은 띠를 차지하는 성격 덕에 프레임최대는 2.672%로 아홉 판 중 가장 작지만 강도평균은 33.1로 가장 밝은 축입니다.
두 그림의 계수를 섞는 진폭·위상 보간
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위상 모프 — 계수는 그대로 두고 위상만 돌리면 무늬가 변신하기

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
별 경로 계수의 진폭은 얼리고 위상에만 주파수 비례 오프셋을 더하면 붓끝 궤적이 별에서 꽃잎·소용돌이로 연속 변신하는데, 크기가 "얼마나" 위상이 "어디서"를 담당하는지를 분리해서 보여 주는 실험입니다. 오프셋은 2.4초 주기의 사인파로 -0.8~0.8을 왕복하고 측정은 누적면적 40.690%·프레임최대 15.098%로 한 프레임 변화가 아홉 판 중 가장 큽니다.
위상에 주파수 비례 오프셋을 더하는 한 줄
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배음 슬라이더 — 원 개수를 줄이면 그림이 어떻게 흐려지나

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
하트 계수를 크기 순으로 준비해 두고 원 개수를 1부터 64까지 슬라이더로 움직이면 하트가 물방울에서 점점 또렷한 하트로 되살아납니다. 자동 시연은 3.2초 주기의 삼각파로 개수를 왕복하고 슬라이더를 직접 잡으면 자동 모드가 꺼지며, 누적면적 41.044%·프레임최대 12.360%입니다.
자동 왕복과 사람의 손이 만나는 프레임
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; });
}
09로고 트레이스 씬 — 어두운 무대 위 한 획 로고 공개 장면

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
"G" 한 획 로고의 윤곽을 03·04와 같은 변환·재합성으로 되그리되 어두운 무대에 중앙 조명을 깔고 트레일 감쇠를 α=0.012로 아홉 판 중 가장 느리게 잡으면, 원들의 회전이 멈춰도 잔상이 오래 머무는 한 장면이 됩니다. 누적면적 47.314%는 이 느린 감쇠가 만든 아홉 판 중 최대치입니다.
원호와 가로획이 이어지는 한 획 경로
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 };
}
로고 공개 장면과 손글씨 데모 — 이 아홉 개를 어디에 붙이나
브랜드 인트로나 로딩 화면이라면 09 로고 트레이스 씬을 그대로 쓰는 것이 가장 빠릅니다. 어두운 무대 위에서 한 획이 완성되는 연출이 이미 잡혀 있어 텍스트만 로고 경로로 바꾸면 됩니다. 방문자가 직접 참여하길 바라는 이벤트 페이지나 방명록에는 05 내 선 그리기를 앉히면, 마우스로 그은 낙서가 그 자리에서 에피사이클로 되살아나는 체험이 됩니다.
오디오·신호 처리를 설명하는 교육 콘텐츠라면 06 스펙트럼 막대가 맞습니다. 계수 크기가 어떤 원이 일하는지를 막대로 보여주므로 푸리에 변환의 추상적인 개념이 눈에 보이는 그래프로 바뀝니다. 세 판 모두 파일 하나로 완결돼 있어 별도 프레임워크 없이 정적 섹션에 바로 끼워 넣을 수 있습니다.
왜 원만 돌면 그림이 나오나? — 복소 계수와 주파수 분해의 셈
점 하나는 복소수 하나, 경로는 복소평면 위의 닫힌 선
평면의 점 (x, y)를 x + iy로 보면 그리고 싶은 선은 복소수의 시퀀스가 되고, 닫힌 경로라면 이 시퀀스는 주기를 한 바퀴로 하는 주기함수로 취급할 수 있습니다. 주기함수는 주파수 서로 다른 원운동의 합으로 분해된다는 것이 푸리에 급수의 출발점이고, 그 분해의 계산이 이 글 03~05·07~09 일곱 판에 들어 있는 이산 변환입니다(02는 손으로 쓴 사인 항, 01은 계수 직접 정의).
겹셈 두 줄이 계수 하나를 만든다
변환의 한 항은 길이 6줄 안팎의 겹셈입니다. 모든 점에 같은 각도의 회전(코사인·사인 짝)을 곱해 더한 뒤 점 수로 나누면 그 주파수의 계수가 실수부·허수부로 나오고, 크기는 두 부분의 피타고라스 합, 위상은 arctan 비율로 읽습니다. 256점이면 겹셈을 256번, 주파수도 256개라 65,536번의 곱셈인데 요즘 브라우저에서는 한 프레임에 수십 번 돌려도 여유이므로 빠른 알고리즘을 가져올 필요가 없습니다.
재합성은 앞 원의 끝점을 다음 원의 중심으로 옮기는 것
계수를 크기 순으로 세운 뒤 시각 u에서 각 원의 각도를 freq·u·2π + 위상으로 잡고 앞 점에서 cos·sin으로 다음 중심을 더하면 체인의 끝점이 원래 경로를 따라 움직입니다. 큰 원부터 얹는 정렬은 체인이 화면 안에서 안정적으로 수렴하는 조건이자 04·08의 "상위 k개만 남긴다"는 압축의 전제입니다. k가 절반을 넘는 주파수는 음의 주파수(k - N)로 접어야 실제 회전 방향이 맞는데, 접지 않으면 닫힌 경로가 똑바로 되살아나지 않습니다.
크기는 얼마나, 위상은 어디서
계수의 두 성질이 그림에서 역할을 나눠 가집니다. 크기는 어떤 주파수가 얼마나 일하는지를 정해 그림의 지문이 되고(06의 막대), 위상은 각 원이 어디서 시작하는지를 정해 무늬의 배열을 바꿉니다(07의 모프). 진폭을 지키고 위상만 돌려도 별이 꽃잎으로 변하는 것도, 위상을 버리고 진폭만 쓰면 원들이 돌아도 붓끝이 그림을 찾지 못하는 것도 푸리에 변환 원리를 이 한 쌍의 분업으로 요약할 수 있는 증거입니다.
회전 속도와 정렬 기준을 바꾸는 세 변형과 접근성
값 하나를 바꾼 세 변형
첫째, 03의 표시 원 개수를 전체에서 상위 5개로 줄이면 꼭짓점이 뭉개져 둥근 별이 됩니다. 둘째, 01의 회전 속도를 1배에서 3배로 올리면 트레일이 길게 늘어나 빗금 무늬가 됩니다. 셋째, 03의 정렬을 크기 순에서 주파수 순으로 바꾸면 붓끝 궤적은 같아도 원 배열의 모양이 완전히 달라집니다. 세 변형 모두 규칙은 건드리지 않고 값 하나만 바꾼다는 점이 이 계산의 성격을 잘 보여줍니다.
움직임을 줄인 사용자에게 남기는 것
아홉 판 모두 matchMedia('(prefers-reduced-motion: reduce)')로 이 설정을 감지해, 켜져 있으면 프레임을 계속 흘리는 대신 궤적을 미리 한 바퀴 쌓은 뒤 마지막 완성 장면을 고정하고 캔버스에 정지 클래스를 붙입니다. 05의 예시 낙서와 09의 완성된 로고 잔상도 같은 방식으로 정지 장면에 남으므로 움직임을 줄인 사용자에게도 그림은 그대로 전달됩니다.
손으로 만지는 두 판과 나머지 일곱 판
05는 포인터 입력을, 08은 키보드 접근이 가능한 슬라이더를 받고 나머지 일곟 판은 손을 대지 않아도 완결됩니다. 05의 캔버스에는 실험판이라는 알 수 있는 이름과 탭 포커스가 붙어 있고 08의 슬라이더에도 원 개수라는 이름이 붙어 있어 화면 낭독기로 조작 흐름을 따라갈 수 있습니다.
계수를 크기 순이 아니라 주파수 순으로 세우면 체인이 흔들리는 함정
계수를 주파수 순으로 세우면 체인이 화면 밖으로 흔들린다
재합성의 수학은 정렬과 무관하게 항상 같은 끝점을 내놓지만 큰 원이 나중에 얹히면 체인의 중심이 크게 흔들려 실제로는 쓸 수 없는 장면이 됩니다. 크기 순 정렬은 안정성의 문제이지 정확도의 문제가 아니라는 점을 03을 고쳐 보며 확인했습니다. 정렬 기준을 바꾸는 변형이 붓끝 궤적은 같아도 원 배열의 모양을 완전히 바꾸는 것도 같은 이유입니다.
05는 점이 늘 때마다 계산을 다시 돌린다
포인터로 그은 선은 점이 하나 늘 때마다 전체 계수를 다시 계산하므로 긴 낙서를 천천히 그으면 재계산이 수백 번 일어납니다. 점을 512개로 잘라 두었기에 체감 흐름은 유지되지만 제한을 크게 올리면 그리는 동안 프레임이 떨리기 시작하니, 촘촘한 선을 원하면 점 수를 늘리기보다 표시할 원의 수를 줄이는 편이 가볍습니다.
감쇠를 0에 가깝게 내리면 잔상이 그림을 지운다
트레일을 지우는 α를 지나치게 낮추면 오래된 궤적이 화면에 남아 새로 그리는 붓끝과 뒤섞여 그림의 윤곽이 흐려집니다. 09가 α=0.012까지 내려 느린 잔상을 연출로 쓸 수 있었던 건 어두운 무대 위 한 획 짜리 단순한 경로였기 때문이고, 별·하트처럼 궤적이 겹치는 그림에서는 03·04의 α=0.03 수준이 잔상과 선명함의 균형점이었습니다.
이 아홉 장면의 소스 전부와 항목별 html·css 는 암호 zip 으로 묶여 있고 비번은 tjy7w4m5 입니다. 풀면 각 판의 index.html 이 그대로 들어 있어 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.
에피사이클 체인을 붙이기 전 자주 나오는 질문
푸리에 변환 공식을 몰라도 이 데모를 쓸 수 있나요?
가능합니다. zip 을 풀면 아홉 항목의 폴더가 각자 들어 있어 원하는 판 하나만 골라 그 파일을 페이지에 올리면 그대로 움직입니다. 색은 파일 머리의 팔레트 토큰을, 크기는 캔버스 폭을 몇 개만 바꿔 조정하며, 각 판은 서로를 참조하지 않는 단일 파일이라 하나만 써도 나머지와 무관합니다.
복소수를 안 쓰고도 에피사이클을 만들 수 있나요?
됩니다. 복소수 겹셈은 결국 코사인·사인 두 줄의 실수 계산과 같아서, 이 글의 코드도 실수부·허수부를 각각 곱해 더하는 방식으로 쓰여 있습니다. 02의 구형파 합성은 아예 사인 합만으로 같은 원리를 보여 주는 판입니다.
푸리에 급수와 푸리에 변환은 무엇이 다른가요?
급수는 닫힌 주기 신호를 주파수별 항의 무한 합으로 펼치는 것이고 변환은 일반 신호에서 각 주파수 성분의 크기를 뽑아 내는 것으로, 이 글의 데모처럼 경로가 닫혀 있고 점이 유한하면 두 관점이 이산 계수라는 같은 결과로 만납니다. 02가 급수의 장면이고 03·04가 변환의 장면에 가깝습니다.
더 읽을거리
- 2D 컨텍스트로 그리는 CanvasRenderingContext2D — MDN
- 다음 프레임을 예약해 애니메이션을 이어 가는 requestAnimationFrame 규격 — MDN
- 배열의 각 항을 변환하는 Array map — MDN
- 주기함수를 주파수 항으로 펼치는 푸리에 급수 — Wikipedia
- 신호에서 주파수 성분을 뽑는 푸리에 변환 — Wikipedia
- 움직임 최소화를 원하는 사용자를 위한 prefers-reduced-motion 미디어 쿼리 — MDN
- 포식자를 피해 무리를 짓는 보이드 시뮬레이션
- 수평 파도를 정현파 겹셈으로 계산하는 캔버스 바다 장면
- 먹이의 화학 신호를 따라 자라는 점균 시뮬레이션









