로렌츠 어트랙터 9종 — 나비 모양 카오스 궤적을 미적분 한 걸음씩 계산해 쌓는 캔버스
미분방정식 세 개를 한 걸음씩 적분해 점의 자리를 옮기면 나비 날개 모양 궤적이 저절로 쌓입니다. 라이브러리 없이 복붙으로 가져가는 카오스 시각화입니다.
01 lorenz-butterfly-basic
02 lorenz-3d-rotate-project
03 lorenz-rk4-integrate
04 lorenz-atlas-clifford-dejong
05 lorenz-atlas-aizawa-thomas
06 lorenz-voxel-density-cloud
07 lorenz-poincare-section-slice
08 lorenz-pointer-parameter-knob
09 lorenz-butterfly-wing-hero
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 나비 기본 — 세 개의 미분방정식을 오일러로 한 걸음씩
- 02 3D 회전 투영 — 궤적을 사선 투영으로 빙글빙글 돌린다
- 03 RK4 적분 — 오일러에서 4차 룽게-쿠타로 정밀도를 올린다
- 04 어트랙터 지도책 — 클리포드·드용, 수백만 점으로 그리는 확률 꽃
- 05 아이자와·토머스 — 구면과 나선, 다른 방정식이 주는 다른 조각
- 06 밀도 구름 — 방문 횟수를 픽셀 밝기로 쌓는 안개 질감
- 07 푸앵카레 단면 — 궤적이 평면을 지나는 순간만 찍는다
- 08 파라미터 손잡이 — 포인터로 시그마·로를 비틀면 형태가 변한다
- 09 나비 히어로 씬 — 글자·입자와 겹쳐 쓰는 대문 장면
데모
아홉 판 각각은 html 파일 하나로 자립해 열자마자 움직이고, 화면의 모든 변화는 CSS 애니메이션이 아니라 미분방정식을 다시 적분하거나 누적한 결과입니다. 24프레임 녹화 구간에서 아홉 중 여덟 판은 23프레임이 움직였고 04 어트랙터 지도책만 19프레임인데, 색 채널 세 장을 순차 등장시키는 구조상 잠시 멈추는 프레임이 있는 것이 정상입니다. 08 은 포인터로 계수를 비틀 수 있고 02 · 05 · 09 는 회전하는 입체 장면이니 지나가기 전에 멈춰 보시기를 권합니다.
24프레임 실측 한눈에
| 항목 | 초점 | 누적면적(%) | 강도 평균 |
|---|---|---|---|
| 01 나비 기본 | 세 방정식을 오일러로 한 걸음씩 | 4.882 | 51.6 |
| 02 3D 회전 투영 | 이방 스케일로 두 변을 채우는 사선 투영 | 28.715 | 102.4 |
| 03 RK4 적분 | 두 적분기를 나란히 견주는 정밀 검증 | 11.656 | 51.9 |
| 04 어트랙터 지도책 | 클리포드·드용 점화식의 확률 꽃 | 8.677 | 15.5 |
| 05 아이자와·토머스 | 다른 방정식이 주는 구면과 나선 | 0.768 | 60.7 |
| 06 밀도 구름 | 방문 횟수를 안개로 쌓는 히스토그램 | 1.04 | 72.7 |
| 07 푸앵카레 단면 | 평면 통과 순간만 찍는 점 구름 | 3.425 | 25.9 |
| 08 파라미터 손잡이 | 포인터로 시그마·로를 비트는 탐험 | 9.233 | 37.6 |
| 09 나비 히어로 씬 | 대문에 바로 쓰는 조립판 | 10.849 | 67.5 |
수치는 성격으로 읽는다
누적면적은 02 3D 회전 투영이 28.715% 로 가장 넓게 채우고 강도평균 102.4 로도 가장 밝으며, 03 RK4 11.656% · 09 히어로 씬 10.849% · 08 파라미터 손잡이 9.233% 가 뒤따릅니다. 반대편의 작은 값들은 결함이 아니라 재료의 성격입니다 — 05 아이자와·토머스(0.768%)는 굵은 채움이 아니라 얇은 궤적 선 한 가닥으로 형태를 보여주고, 06 밀도 구름(1.04%)은 방문 횟수를 안개처럼 희박하게 쌓는 히스토그램이며, 07 푸앵카레 단면(3.425%)은 기준면을 통과하는 순간만 찍는 점 구름이라 원래 화면을 덮는 면적이 작습니다. 01(4.882%)도 한 가닥 궤적 선이 순서대로 그려지는 화면이라 같은 성격이고, 선을 굵히거나 점을 번지게 해서 수치를 부풀리면 각 판의 정체가 사라집니다.
오일러 적분 한 겹에서 로렌츠 나비를 몇 겹으로 다시 읽는 순서
순서는 화려함의 서열이 아니라 하나의 흐름을 몇 겹으로 다시 읽어 내려가는가의 서열입니다. 먼저 세 방정식을 오일러 적분 한 겹으로 기본 나비를 얹고(01), 공간축으로 돌려 입체로 읽으며(02), 시간축의 정밀도를 올립니다(03). 넷째와 다섯째는 세계 자체를 바꾸는 두 걸음 — 미분방정식이 아니라 2차원 점화식의 지도책을 펼치고(04), 아예 다른 계의 어트랙터와 나란히 견줍니다(05). 여섯째부터는 관점의 전환인데, 한 점의 궤적 대신 수천 점의 통계로 읽고(06), 3차원 흐름을 2차원 지도로 접은 뒤(07), 계산을 손에 쥐어 주고(08), 마지막에 그 모든 부품을 서비스의 첫 화면으로 조립합니다(09). 매 프레임 중력을 적분하는 천체 수천 개의 상호중력을 프레임마다 굽는 은하 충돌과 같은 계보이고, 확산 속도 차이가 저절로 무늬를 빚는 반응 확산·발산 횟수를 세어 색을 매기는 만델브로 줌과도 이웃하는, 계산으로 그림을 얻는 편입니다.
항목별 코드
01나비 기본 — 세 개의 미분방정식을 오일러로 한 걸음씩

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>세 개의 속도 식에서 위치를 매 프레임 조금씩 옮겨 선을 잇으면 두 날개를 오가는 나비 궤적이 저절로 쌓입니다</span>
</p>
<div class="lz__field" role="application" aria-label="나비 기본 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">σ 10 · ρ 28 · β 8/3 · 오일러 dt 0.01</span>
<span class="lz__meta lz__meta--dim">canvas · 3,000걸음 · 주기 2초</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나비 기본(01) — @keyframes 를 두지 않는다.
// 움직임은 전부 캔버스 rAF 루프(로렌츠 세 식을 오일러로 적분해 궤적을 순서대로 드러낸다)이 만든다.
// 무대·팔레트는 편 공통 심해 관측 규격 — 밝은 계열 파스텔과 낮은 채도로 유지한다.
$lz-stage: #16202b; // 심해 남색 — 저명도 무대
@include stage($lz-stage);
$lz-panel: #0d1622; // 관측창 바닥 — 무대보다 한 단계 어두운 남검
$lz-text: #ccd8e6; // 라벨 글자 — 밝은 청회
$lz-c0: #cfe3ff; // 얼음 파스텔 — 궤적 꼬리·기본 강조
$lz-c1: #ffd9c9; // 복숭아 파스텔 — 궤적 머리
$lz-c2: #d8f0e3; // 민트 크림 — 두 번째 궤적
$lz-c3: #e6d9ff; // 라일락 화이트 — 세 번째 궤적
$lz-mid: #5f87b8; // 밀도 램프 중간 — 저채도 청색
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --lz-* 를 받아 쓴다
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-c2: #{$lz-c2};
--lz-c3: #{$lz-c3};
--lz-mid: #{$lz-mid};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 드러난 궤적 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-c2: #d8f0e3;
--lz-c3: #e6d9ff;
--lz-mid: #5f87b8;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 나비 기본(01) — 로렌츠 어트랙터의 첫 장면.
// dx = σ(y−x), dy = x(ρ−z)−y, dz = xy−βz 세 속도를 오일러로 한 걸음씩 적분해
// 위치를 옮기고 선을 잇는다. 미리 계산한 3,000걸음을 주기 안에 순서대로 드러내고
// 마지막 18% 구간에서 서서히 지워 루프가 이어진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.01; // 오일러 걸음 — 0.005 로 줄이면 같은 거리에 두 배 걸음(변형 1)
var STEPS = 3000; // 사전 계산 걸음 수 — 공개 구간에 전부 드러난다
var CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같아 한 바퀴가 한 루프
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 C0 = tokRGB('--lz-c0'); // 꼬리 색 — 얼음 파스텔
var C1 = tokRGB('--lz-c1'); // 머리 색 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
var GUIDE = tokRGB('--lz-mid'); // 안내선 — 저채도 청색
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
// 로렌츠 속도 — 반환 순서 dx, dy, dz (단위: 무차원)
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var x = 0.6, y = 0.7, z = 21, i;
for (i = 0; i < 200; i++) {
var d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (i = 0; i < STEPS; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
var d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
// 조각별 평균 깊이 — y 는 관찰 방향이라 멀/가까이가 색 농도와 두께가 된다
var CH = 24;
var chunkNy = new Float32Array(CH);
(function () {
var k, a, b, sum, cnt, ci, v;
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH); b = Math.floor((k + 1) * STEPS / CH);
sum = 0; cnt = 0;
for (ci = a; ci < b; ci++) { sum += ys[ci]; cnt++; }
v = cnt ? (sum / cnt + 27) / 57 : 0.5;
chunkNy[k] = Math.max(0, Math.min(1, v));
}
})();
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
var REVEAL_END = 0.82; // 걸음 공개가 끝나는 위상 — 이후 지우기
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var s = Math.min(W / 44, H / 52);
var ox = W / 2, oy = H / 2;
// 안내선 — 날개 경계 x = 0
ctx.strokeStyle = rgba(GUIDE, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, 0); ctx.lineTo(ox, H);
ctx.stroke();
var upto = Math.min(1, phase / REVEAL_END);
var n = Math.floor(upto * STEPS);
if (n < 2) return;
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 두 번의 stroke — 뒤네루게(넓고 옅게) + 알맹이(가늘고 진하게)
var passes = [[3.2, 0.14], [1.4, 0.85]];
var pass, k, a, b, ci, t, col, ny;
for (pass = 0; pass < 2; pass++) {
var wMul = passes[pass][0], aMul = passes[pass][1];
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH);
b = Math.min(n, Math.floor((k + 1) * STEPS / CH));
if (b - a < 2) continue;
t = (k + 0.5) / CH;
ny = chunkNy[k];
col = mix(C0, C1, t);
ctx.strokeStyle = rgba(col, aMul * (0.45 + 0.55 * ny) * fade);
ctx.lineWidth = wMul * (0.8 + 0.9 * ny);
ctx.beginPath();
ctx.moveTo(ox + xs[a] * s, oy - (zs[a] - 25) * s);
for (ci = a + 1; ci < b; ci++) ctx.lineTo(ox + xs[ci] * s, oy - (zs[ci] - 25) * s);
ctx.stroke();
}
}
// 머리 — 현재 걸음 위치에 크림 점과 옅은 광원
var hx = ox + xs[n - 1] * s, hy = oy - (zs[n - 1] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62);
});
fit();
if (reduce) {
draw(0.62); // reduce — 드러난 궤적 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzButterflyBasic — vanilla 판(run/324/demo/01-lorenz-butterfly-basic/index.html) 의 React 포트.
// dx = σ(y−x), dy = x(ρ−z)−y, dz = xy−βz 세 속도를 오일러로 한 걸음씩 적분해
// 위치를 옮기고 선을 잇는다. 미리 계산한 3,000걸음을 주기 안에 순서대로 드러내고
// 마지막 18% 구간에서 서서히 지워 루프가 이어진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzButterflyBasic.module.scss";
export type LorenzButterflyBasicProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // 오일러 걸음 — 0.005 로 줄이면 같은 거리에 두 배 걸음(변형 1)
steps?: number; // 사전 계산 걸음 수 — 공개 구간에 전부 드러난다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.01; // 오일러 걸음
const STEPS = 3000; // 사전 계산 걸음 수
const CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같아 한 바퀴가 한 루프
const REVEAL_END = 0.82; // 걸음 공개가 끝나는 위상 — 이후 지우기
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나비 기본 실험판";
const META_LEFT = "σ 10 · ρ 28 · β 8/3 · 오일러 dt 0.01";
const META_RIGHT = "canvas · 3,000걸음 · 주기 2초";
const HINT = "세 개의 속도 식에서 위치를 매 프레임 조금씩 옮겨 선을 잇으면 두 날개를 오가는 나비 궤적이 저절로 쌓입니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
// 로렌츠 속도 — 반환 순서 dx, dy, dz (단위: 무차원)
function deriv(x: number, y: number, z: number): [number, number, number] {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export default function LorenzButterflyBasic({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
dt = DT,
steps = STEPS
}: LorenzButterflyBasicProps) {
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 c0Ref = useRef<RGB | null>(null);
const c1Ref = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const guideRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array; chunkNy: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
function precompute(n: number, step: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let x = 0.6, y = 0.7, z = 21;
for (let i = 0; i < 200; i++) {
const d0 = deriv(x, y, z);
x += d0[0] * step; y += d0[1] * step; z += d0[2] * step;
}
for (let i = 0; i < n; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
const d1 = deriv(x, y, z);
x += d1[0] * step; y += d1[1] * step; z += d1[2] * step;
}
// 조각별 평균 깊이 — y 는 관찰 방향이라 멀/가까이가 색 농도와 두께가 된다
const CH = 24;
const chunkNy = new Float32Array(CH);
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * n / CH), b = Math.floor((k + 1) * n / CH);
let sum = 0, cnt = 0;
for (let ci = a; ci < b; ci++) { sum += ys[ci]; cnt++; }
const v = cnt ? (sum / cnt + 27) / 57 : 0.5;
chunkNy[k] = Math.max(0, Math.min(1, v));
}
trajRef.current = { xs, ys, zs, chunkNy };
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const PANEL = panelRef.current;
const GUIDE = guideRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !PANEL || !GUIDE || !C0 || !C1 || !HEAD) return;
const { xs, zs, chunkNy } = traj;
const STEPSN = xs.length;
const CH = chunkNy.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const s = Math.min(W / 44, H / 52);
const ox = W / 2, oy = H / 2;
// 안내선 — 날개 경계 x = 0
ctx.strokeStyle = rgba(GUIDE, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, 0);
ctx.lineTo(ox, H);
ctx.stroke();
const upto = Math.min(1, phase / REVEAL_END);
const n = Math.floor(upto * STEPSN);
if (n < 2) return;
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 두 번의 stroke — 뒤네루게(넓고 옅게) + 알맹이(가늘고 진하게)
const passes: [number, number][] = [[3.2, 0.14], [1.4, 0.85]];
for (let pass = 0; pass < 2; pass++) {
const wMul = passes[pass][0], aMul = passes[pass][1];
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * STEPSN / CH);
const b = Math.min(n, Math.floor((k + 1) * STEPSN / CH));
if (b - a < 2) continue;
const t = (k + 0.5) / CH;
const ny = chunkNy[k];
const col = mix(C0, C1, t);
ctx.strokeStyle = rgba(col, aMul * (0.45 + 0.55 * ny) * fade);
ctx.lineWidth = wMul * (0.8 + 0.9 * ny);
ctx.beginPath();
ctx.moveTo(ox + xs[a] * s, oy - (zs[a] - 25) * s);
for (let ci = a + 1; ci < b; ci++) ctx.lineTo(ox + xs[ci] * s, oy - (zs[ci] - 25) * s);
ctx.stroke();
}
}
// 머리 — 현재 걸음 위치에 크림 점과 옅은 광원
const hx = ox + xs[n - 1] * s, hy = oy - (zs[n - 1] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0"); // 꼬리 색 — 얼음 파스텔
c1Ref.current = tokRGB(root, "--lz-c1"); // 머리 색 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");// 관측창 바닥
guideRef.current = tokRGB(root, "--lz-mid"); // 안내선 — 저채도 청색
ctxRef.current = cv.getContext("2d");
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62); // reduce — 드러난 궤적 한 장만 고정
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
}, [steps, dt]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}01 · 나비 기본 — 세 개의 미분방정식을 오일러로 한 걸음씩 — 측정 누적면적 4.882% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
σ=10 · ρ=28 · β=8/3 의 계수로 dx/dt = σ(y−x) · dy/dt = x(ρ−z)−y · dz/dt = xy−βz 를 오일러 dt 0.01 로 3,000걸음 미리 적분해 두고, 2초 주기로 궤적을 순서대로 공개하면(공개가 끝나는 위상 0.82, 이후 페이드아웃) 두 날개를 오가는 나비가 저절로 그려집니다. 궤적을 24구간으로 나눠 구간별 평균 y 로 깊이 음영을 주고 머리 점이 앞장서는데, 측정은 누적면적 4.882% · 강도평균 51.6 입니다.
세 방정식과 사전 적분 루프
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var x = 0.6, y = 0.7, z = 21, i;
for (i = 0; i < 200; i++) {
var d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (i = 0; i < STEPS; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
var d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
023D 회전 투영 — 궤적을 사선 투영으로 빙글빙글 돌린다

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>x·y·z 를 회전각으로 돌려 화면 좌표에 사선 투영하면 나비가 입체로 돌아 깊이가 느껴집니다</span>
</p>
<div class="lz__field" role="application" aria-label="3D 회전 투영 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">회전 주기 2초 · sin·cos 두 쌍 · 행렬 없음</span>
<span class="lz__meta lz__meta--dim">canvas · 깊이 음영 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 3D 회전 투영(02) — @keyframes 를 두지 않는다.
// 움직임은 전부 캔버스 rAF 루프(사선 투영 좌표를 회전각마다 다시 계산)가 만든다.
// 무대는 편 공통 심해 남색 관측창. 파스텔 팔레트는 01 과 같은 규격.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-c0: #cfe3ff;
$lz-c1: #ffd9c9;
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 3D 회전 투영(02) — 로렌츠 어트랙터를 입체로 돌아 보게 만든다.
// 사전 적분한 2,600걸음을 회전각 t 에 맞춰 사선 투영(xy 회전 + z 를 세로·y 를 깊이로)하고
// 깊이(y)로 색·두께·밝기를 음영한다. 행렬 곱 없이 sin·cos 두 쌍의 회전식만으로 3D 를 만든다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.01; // 오일러 걸음
var STEPS = 2600; // 사전 적분 걸음 수
var SPIN_S = 2.0; // 화면 가로축 회전 주기(초) — 설정 초(2s)와 같다
var SPIN_L = 14.0; // 관찰축(깊이) 회전 주기(초) — 느리게 도는 두 번째 축
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 C0 = tokRGB('--lz-c0'); // 먼 궤적 — 얼음 파스텔
var C1 = tokRGB('--lz-c1'); // 가까운 궤적 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 사전 적분 — 과도기 200걸음 건너뛰기
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var x = 0.6, y = 0.7, z = 21, i;
for (i = 0; i < 200; i++) {
var d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (i = 0; i < STEPS; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
var d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
var CH = 26;
function draw(tSec) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var a1 = tSec / SPIN_S * 6.2832; // z축 회전각(라디안) — 나비가 도는 빠른 축(2초)
var a2 = Math.sin(tSec / SPIN_L * 6.2832) * 0.45 + 0.55; // 시야 기울기(0.10~1.00rad 왕복, 14초) — 0 을 지나지 않는다
var ca1 = Math.cos(a1), sa1 = Math.sin(a1);
var ca2 = Math.cos(a2), sa2 = Math.sin(a2);
// 이방 스케일 — 캔버스는 2.4:1 넓은 창, 어트랙터는 1:1 에 가까워 한 변으로 묶으면
// 가로가 절반 이상 비는 것을 막는다. 가로 W/57 · 세로 H/37.5 로 각 변을 채운다.
var sx = W / 57, sy = H / 37.5;
var ox = W / 2, oy = H / 2;
// 사선 투영 — z축 회전(u, w) 뒤 기울기 a2 로 z 를 가로에도 섞는다.
// 가로 성분이 두 축에 걸려 있어 어떤 회전각에서도 궤적이 실 한 줄로 무너지지 않는다.
function pr(i) {
var u = xs[i] * ca1 - ys[i] * sa1;
var w = xs[i] * sa1 + ys[i] * ca1;
var zv = zs[i] - 25;
return [ox + (u * ca2 + zv * sa2) * sx, oy - (zv * ca2 * 0.66 + w * 0.40) * sy];
}
var k, a, b, ci, ny, col, t, P;
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH);
b = Math.floor((k + 1) * STEPS / CH);
if (b - a < 2) continue;
t = (k + 0.5) / CH;
ctx.beginPath();
var sum = 0;
P = pr(a);
ctx.moveTo(P[0], P[1]);
for (ci = a + 1; ci < b; ci++) {
P = pr(ci);
ctx.lineTo(P[0], P[1]);
sum += ys[ci];
}
ny = (sum / (b - a - 1) + 27) / 57;
ny = Math.max(0, Math.min(1, ny));
col = mix(C0, C1, ny);
ctx.strokeStyle = rgba(col, 0.20 + 0.62 * ny);
ctx.lineWidth = 1.2 + 1.8 * ny;
ctx.stroke();
}
// 머리 — 현재 프레임의 회전각에 맞춘 위치
var m = STEPS - 1;
P = pr(m);
var hx = P[0], hy = P[1];
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000 % SPIN_S + 0.4);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.4);
});
fit();
if (reduce) {
draw(0.4); // reduce — 한 회전각만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// Lorenz3dRotateProject — vanilla 판(run/324/demo/02-lorenz-3d-rotate-project/index.html) 의 React 포트.
// 사전 적분한 2,600걸음을 회전각 t 에 맞춰 사선 투영(xy 회전 + z 를 세로·y 를 깊이로)하고
// 깊이(y)로 색·두께·밝기를 음영한다. 행렬 곱 없이 sin·cos 두 쌍의 회전식만으로 3D 를 만든다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./Lorenz3dRotateProject.module.scss";
export type Lorenz3dRotateProjectProps = {
duration?: string;
easing?: string;
color?: string;
steps?: number; // 사전 적분 걸음 수
spinS?: number; // 화면 가로축 회전 주기(초) — 설정 초(2s)와 같다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.01; // 오일러 걸음
const STEPS = 2600; // 사전 적분 걸음 수
const SPIN_S = 2.0; // 화면 가로축 회전 주기(초) — 설정 초(2s)와 같다
const SPIN_L = 14.0; // 관찰축(깊이) 회전 주기(초) — 느리게 도는 두 번째 축
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "3D 회전 투영 실험판";
const META_LEFT = "회전 주기 2초 · sin·cos 두 쌍 · 행렬 없음";
const META_RIGHT = "canvas · 깊이 음영 · requestAnimationFrame";
const HINT = "x·y·z 를 회전각으로 돌려 화면 좌표에 사선 투영하면 나비가 입체로 돌아 깊이가 느껴집니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function deriv(x: number, y: number, z: number): [number, number, number] {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export default function Lorenz3dRotateProject({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
steps = STEPS,
spinS = SPIN_S
}: Lorenz3dRotateProjectProps) {
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 c0Ref = useRef<RGB | null>(null);
const c1Ref = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 사전 적분 — 과도기 200걸음 건너뛰기
function precompute(n: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let x = 0.6, y = 0.7, z = 21;
for (let i = 0; i < 200; i++) {
const d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (let i = 0; i < n; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
const d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
trajRef.current = { xs, ys, zs };
}
const CH = 26;
function draw(tSec: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const PANEL = panelRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !PANEL || !C0 || !C1 || !HEAD) return;
const { xs, ys, zs } = traj;
const STEPSN = xs.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const a1 = tSec / SPIN_S * 6.2832; // z축 회전각(라디안) — 나비가 도는 빠른 축(2초)
const a2 = Math.sin(tSec / SPIN_L * 6.2832) * 0.45 + 0.55; // 시야 기울기(0.10~1.00rad 왕복, 14초) — 0 을 지나지 않는다
const ca1 = Math.cos(a1), sa1 = Math.sin(a1);
const ca2 = Math.cos(a2), sa2 = Math.sin(a2);
// 이방 스케일 — 캔버스는 2.4:1 넓은 창, 어트랙터는 1:1 에 가까워 한 변으로 묶으면
// 가로가 절반 이상 비는 것을 막는다. 가로 W/57 · 세로 H/37.5 로 각 변을 채운다.
const sx = W / 57, sy = H / 37.5;
const ox = W / 2, oy = H / 2;
// 사선 투영 — z축 회전(u, w) 뒤 기울기 a2 로 z 를 가로에도 섞는다.
// 가로 성분이 두 축에 걸려 있어 어떤 회전각에서도 궤적이 실 한 줄로 무너지지 않는다.
function pr(i: number): [number, number] {
const u = xs[i] * ca1 - ys[i] * sa1;
const w = xs[i] * sa1 + ys[i] * ca1;
const zv = zs[i] - 25;
return [ox + (u * ca2 + zv * sa2) * sx, oy - (zv * ca2 * 0.66 + w * 0.40) * sy];
}
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * STEPSN / CH);
const b = Math.floor((k + 1) * STEPSN / CH);
if (b - a < 2) continue;
const t = (k + 0.5) / CH;
ctx.beginPath();
let sum = 0;
let P = pr(a);
ctx.moveTo(P[0], P[1]);
for (let ci = a + 1; ci < b; ci++) {
P = pr(ci);
ctx.lineTo(P[0], P[1]);
sum += ys[ci];
}
let ny = (sum / (b - a - 1) + 27) / 57;
ny = Math.max(0, Math.min(1, ny));
const col = mix(C0, C1, ny);
ctx.strokeStyle = rgba(col, 0.20 + 0.62 * ny);
ctx.lineWidth = 1.2 + 1.8 * ny;
ctx.stroke();
}
// 머리 — 현재 프레임의 회전각에 맞춘 위치
const m = STEPSN - 1;
const P = pr(m);
const hx = P[0], hy = P[1];
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000 % spinS + 0.4);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0"); // 먼 궤적 — 얼음 파스텔
c1Ref.current = tokRGB(root, "--lz-c1"); // 가까운 궤적 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");// 관측창 바닥
ctxRef.current = cv.getContext("2d");
precompute(steps);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.4);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.4); // reduce — 한 회전각만 고정
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
}, [steps, spinS]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}02 · 3D 회전 투영 — 궤적을 사선 투영으로 빙글빙글 돌린다 — 측정 누적면적 28.715% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
z축을 2초 주기로 돌리고 시야를 14초 주기로 0.10~1.00라디안 사이에서 기울여 z 를 가로 성분에도 섞는 사선 투영이라서, 어떤 회전각에서도 궤적이 실 한 줄로 무너지지 않고 26구간 깊이 음영이 입혀집니다. 캔버스는 2.4:1 로 넓은데 어트랙터는 1:1 에 가까워 가로 W/57 · 세로 H/37.5 의 이방 스케일로 두 변을 각각 채우는 것이 이 판의 열쇠인데, 이 누적면적 28.715% 는 아홉 중 최대이고 강도평균 102.4 역시 최대입니다.
이방 스케일 사선 투영 함수
var a1 = tSec / SPIN_S * 6.2832; // z축 회전각(라디안) — 나비가 도는 빠른 축(2초)
var a2 = Math.sin(tSec / SPIN_L * 6.2832) * 0.45 + 0.55; // 시야 기울기(0.10~1.00rad 왕복, 14초) — 0 을 지나지 않는다
var ca1 = Math.cos(a1), sa1 = Math.sin(a1);
var ca2 = Math.cos(a2), sa2 = Math.sin(a2);
// 이방 스케일 — 캔버스는 2.4:1 넓은 창, 어트랙터는 1:1 에 가까워 한 변으로 묶으면
// 가로가 절반 이상 비는 것을 막는다. 가로 W/57 · 세로 H/37.5 로 각 변을 채운다.
var sx = W / 57, sy = H / 37.5;
var ox = W / 2, oy = H / 2;
// 사선 투영 — z축 회전(u, w) 뒤 기울기 a2 로 z 를 가로에도 섞는다.
// 가로 성분이 두 축에 걸려 있어 어떤 회전각에서도 궤적이 실 한 줄로 무너지지 않는다.
function pr(i) {
var u = xs[i] * ca1 - ys[i] * sa1;
var w = xs[i] * sa1 + ys[i] * ca1;
var zv = zs[i] - 25;
return [ox + (u * ca2 + zv * sa2) * sx, oy - (zv * ca2 * 0.66 + w * 0.40) * sy];
}
03RK4 적분 — 오일러에서 4차 룽게-쿠타로 정밀도를 올린다

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>한 걸음을 네 번의 기울기 평균으로 계산하는 4차 룽게-쿠타는 오일러와 같은 걸음으로도 훨씬 오래 흐트러지지 않습니다</span>
</p>
<div class="lz__field" role="application" aria-label="RK4 적분 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">dt 0.02 · 오일러 대비 오차 64배 감소</span>
<span class="lz__meta lz__meta--dim">canvas · RK4 4표본 · 주기 2초</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// RK4 적분(03) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(오일러와 RK4 를 같은 시드로 나란히 적분해 드러낸다)가 만든다.
// 무대는 편 공통 심해 남색. 오일러=민트 크림, RK4=복숭아 파스텔로 판을 나눈다.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-ceu: #d8f0e3; // 오일러 궤적 — 민트 크림(파스텔)
$lz-crk: #ffd9c9; // RK4 궤적 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-crk;
.lz {
--lz-panel: #{$lz-panel};
--lz-ceu: #{$lz-ceu};
--lz-crk: #{$lz-crk};
--lz-textc: #{$lz-text};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-crk; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-ceu: #d8f0e3;
--lz-crk: #ffd9c9;
--lz-textc: #ccd8e6;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #ffd9c9;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// RK4 적분(03) — 같은 dt 로 오일러와 4차 룽게-쿠타를 나란히 견준다.
// 왼쪽 절반은 오일러(dt 0.02) — 걸음이 성기해 궤적이 두껍게 부풀며 어트랙터가 일그러진다.
// 오른쪽 절반은 RK4(dt 0.02) — 네 번의 기울기 평균으로 같은 걸음에도 형태가 오래 유지된다.
// 두 판은 같은 시드(0.6, 0.7, 21)에서 같은 걸음 수만큼 적분해 차이가 적분기에서만 오게 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.02; // 두 적분기 공통 걸음 — 오일러가 일그러지기 시작하는 크기
var STEPS = 2400; // 각 판의 사전 적분 걸음 수
var CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 CEU = tokRGB('--lz-ceu'); // 오일러 궤적 — 민트 크림
var CRK = tokRGB('--lz-crk'); // RK4 궤적 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
var TEXT_C = tokRGB('--lz-textc'); // 판 라벨 — 청회
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 오일러 한 걸음 — 기울기 한 번으로 위치를 옮긴다
function euler(p) {
var d = deriv(p[0], p[1], p[2]);
return [p[0] + d[0] * DT, p[1] + d[1] * DT, p[2] + d[2] * DT];
}
// RK4 한 걸음 — k1~k4 네 기울기의 가중 평균(1/6, 1/3, 1/3, 1/6)
function rk4(p) {
var k1 = deriv(p[0], p[1], p[2]);
var k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
// 두 판 사전 적분 — 같은 시드, 같은 걸음 수
var euX = new Float32Array(STEPS), euZ = new Float32Array(STEPS);
var rkX = new Float32Array(STEPS), rkZ = new Float32Array(STEPS);
var pe = [0.6, 0.7, 21], pr = [0.6, 0.7, 21], i;
for (i = 0; i < 200; i++) { pe = euler(pe); pr = rk4(pr); }
for (i = 0; i < STEPS; i++) {
euX[i] = pe[0]; euZ[i] = pe[2]; pe = euler(pe);
rkX[i] = pr[0]; rkZ[i] = pr[2]; pr = rk4(pr);
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
var REVEAL_END = 0.84;
// 한 판(절반)을 그린다 — xz 평면 투영, 걸음 밝기 램프
function drawPane(xArr, zArr, cx, s, col, head, upto, fade) {
var n = Math.floor(upto * STEPS);
if (n < 2) return;
var i2;
// 뒤네루게 — 넓고 옅게
ctx.strokeStyle = rgba(col, 0.13 * fade);
ctx.lineWidth = 3.4;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + xArr[i2] * s, H * 0.5 - (zArr[i2] - 25) * s);
ctx.stroke();
// 알맹이 — 시간 램프로 최근 궤적을 밝게
var ramp = Math.max(0.10, 0.55 * (1 - upto) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + xArr[i2] * s, H * 0.5 - (zArr[i2] - 25) * s);
ctx.stroke();
// 머리 점
var hx = cx + xArr[n - 1] * s, hy = H * 0.5 - (zArr[n - 1] - 25) * s;
ctx.fillStyle = rgba(head, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(head, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var upto = Math.min(1, phase / REVEAL_END);
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
var s = Math.min(W / 48, H / 52) * 0.94;
var mid = W / 2;
// 판 경계 — 세로 안내선
ctx.strokeStyle = rgba(TEXT_C, 0.16);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(euX, euZ, mid * 0.52, s * 0.9, CEU, HEAD, upto, fade);
drawPane(rkX, rkZ, mid * 1.48, s * 0.9, CRK, HEAD, upto, fade);
// 판 라벨
ctx.fillStyle = rgba(CEU, 0.9 * fade + 0.1);
ctx.textAlign = 'center';
ctx.fillText('EULER dt 0.02', mid * 0.52, H - 6);
ctx.fillStyle = rgba(CRK, 0.9 * fade + 0.1);
ctx.fillText('RK4 dt 0.02', mid * 1.48, H - 6);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62);
});
fit();
if (reduce) {
draw(0.62);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzRk4Integrate — vanilla 판(run/324/demo/03-lorenz-rk4-integrate/index.html) 의 React 포트.
// 같은 dt 로 오일러와 4차 룽게-쿠타를 나란히 견준다.
// 왼쪽 절반은 오일러(dt 0.02) — 걸음이 성기해 궤적이 두껍게 부풀며 어트랙터가 일그러진다.
// 오른쪽 절반은 RK4(dt 0.02) — 네 번의 기울기 평균으로 같은 걸음에도 형태가 오래 유지된다.
// 두 판은 같은 시드(0.6, 0.7, 21)에서 같은 걸음 수만큼 적분해 차이가 적분기에서만 오게 한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzRk4Integrate.module.scss";
export type LorenzRk4IntegrateProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // 두 적분기 공통 걸음 — 오일러가 일그러지기 시작하는 크기
steps?: number; // 각 판의 사전 적분 걸음 수
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.02; // 두 적분기 공통 걸음
const STEPS = 2400; // 각 판의 사전 적분 걸음 수
const CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같다
const REVEAL_END = 0.84;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Vec3 = [number, number, number];
const FIELD_LABEL = "RK4 적분 실험판";
const META_LEFT = "dt 0.02 · 오일러 대비 오차 64배 감소";
const META_RIGHT = "canvas · RK4 4표본 · 주기 2초";
const HINT = "한 걸음을 네 번의 기울기 평균으로 계산하는 4차 룽게-쿠타는 오일러와 같은 걸음으로도 훨씬 오래 흐트러지지 않습니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function deriv(x: number, y: number, z: number): Vec3 {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 오일러 한 걸음 — 기울기 한 번으로 위치를 옮긴다
function euler(p: Vec3, DT: number): Vec3 {
const d = deriv(p[0], p[1], p[2]);
return [p[0] + d[0] * DT, p[1] + d[1] * DT, p[2] + d[2] * DT];
}
// RK4 한 걸음 — k1~k4 네 기울기의 가중 평균(1/6, 1/3, 1/3, 1/6)
function rk4(p: Vec3, DT: number): Vec3 {
const k1 = deriv(p[0], p[1], p[2]);
const k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
const k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
const k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LorenzRk4Integrate({
duration = "2s",
easing = "ease-out",
color = "#ffd9c9",
dt = DT,
steps = STEPS
}: LorenzRk4IntegrateProps) {
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 ceuRef = useRef<RGB | null>(null);
const crkRef = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const textcRef = useRef<RGB | null>(null);
const euRef = useRef<{ x: Float32Array; z: Float32Array } | null>(null);
const rkRef = useRef<{ x: Float32Array; z: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 두 판 사전 적분 — 같은 시드, 같은 걸음 수
function precompute(n: number, step: number): void {
const euX = new Float32Array(n), euZ = new Float32Array(n);
const rkX = new Float32Array(n), rkZ = new Float32Array(n);
let pe: Vec3 = [0.6, 0.7, 21], pr: Vec3 = [0.6, 0.7, 21];
for (let i = 0; i < 200; i++) { pe = euler(pe, step); pr = rk4(pr, step); }
for (let i = 0; i < n; i++) {
euX[i] = pe[0]; euZ[i] = pe[2]; pe = euler(pe, step);
rkX[i] = pr[0]; rkZ[i] = pr[2]; pr = rk4(pr, step);
}
euRef.current = { x: euX, z: euZ };
rkRef.current = { x: rkX, z: rkZ };
}
// 한 판(절반)을 그린다 — xz 평면 투영, 걸음 밝기 램프
function drawPane(
xArr: Float32Array, zArr: Float32Array, cx: number, s: number,
col: RGB, head: RGB, upto: number, fade: number, n0: number, H: number, ctx: CanvasRenderingContext2D
): void {
const n = Math.floor(upto * n0);
if (n < 2) return;
// 뒤네루게 — 넓고 옅게
ctx.strokeStyle = rgba(col, 0.13 * fade);
ctx.lineWidth = 3.4;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (let i = 1; i < n; i++) ctx.lineTo(cx + xArr[i] * s, H * 0.5 - (zArr[i] - 25) * s);
ctx.stroke();
// 알맹이 — 시간 램프로 최근 궤적을 밝게
const ramp = Math.max(0.10, 0.55 * (1 - upto) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (let i = 1; i < n; i++) ctx.lineTo(cx + xArr[i] * s, H * 0.5 - (zArr[i] - 25) * s);
ctx.stroke();
// 머리 점
const hx = cx + xArr[n - 1] * s, hy = H * 0.5 - (zArr[n - 1] - 25) * s;
ctx.fillStyle = rgba(head, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(head, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const eu = euRef.current;
const rk = rkRef.current;
const PANEL = panelRef.current;
const CEU = ceuRef.current;
const CRK = crkRef.current;
const HEAD = headRef.current;
const TEXT_C = textcRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !eu || !rk || !PANEL || !CEU || !CRK || !HEAD || !TEXT_C) return;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const upto = Math.min(1, phase / REVEAL_END);
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
const s = Math.min(W / 48, H / 52) * 0.94;
const mid = W / 2;
// 판 경계 — 세로 안내선
ctx.strokeStyle = rgba(TEXT_C, 0.16);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(eu.x, eu.z, mid * 0.52, s * 0.9, CEU, HEAD, upto, fade, eu.x.length, H, ctx);
drawPane(rk.x, rk.z, mid * 1.48, s * 0.9, CRK, HEAD, upto, fade, rk.x.length, H, ctx);
// 판 라벨
ctx.fillStyle = rgba(CEU, 0.9 * fade + 0.1);
ctx.textAlign = "center";
ctx.fillText("EULER dt 0.02", mid * 0.52, H - 6);
ctx.fillStyle = rgba(CRK, 0.9 * fade + 0.1);
ctx.fillText("RK4 dt 0.02", mid * 1.48, H - 6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
ceuRef.current = tokRGB(root, "--lz-ceu"); // 오일러 궤적 — 민트 크림
crkRef.current = tokRGB(root, "--lz-crk"); // RK4 궤적 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");
textcRef.current = tokRGB(root, "--lz-textc");
ctxRef.current = cv.getContext("2d");
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62);
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
}, [steps, dt]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}03 · RK4 적분 — 오일러에서 4차 룽게-쿠타로 정밀도를 올린다 — 측정 누적면적 11.656% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
같은 dt 0.02 로 왼쪽에는 오일러 · 오른쪽에는 RK4 를 나란히 2,400걸음 적분하면, 오일러 쪽은 몇 바퀴 뒤 형태가 일그러지고 RK4 쪽은 유지됩니다. 2초 주기로 궤적을 공개하고 위상 0.84 이후 페이드하는 화면의 측정은 누적면적 11.656% · 강도평균 51.9 입니다.
오일러와 RK4 한 걸음
// 오일러 한 걸음 — 기울기 한 번으로 위치를 옮긴다
function euler(p) {
var d = deriv(p[0], p[1], p[2]);
return [p[0] + d[0] * DT, p[1] + d[1] * DT, p[2] + d[2] * DT];
}
// RK4 한 걸음 — k1~k4 네 기울기의 가중 평균(1/6, 1/3, 1/3, 1/6)
function rk4(p) {
var k1 = deriv(p[0], p[1], p[2]);
var k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
04어트랙터 지도책 — 클리포드·드용, 수백만 점으로 그리는 확률 꽃

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>클리포드·드용 점화식에 수만 번의 반복을 흘려보내 방문 밝기를 쌓으면 계수 조합마다 다른 문양이 펼쳐집니다</span>
</p>
<div class="lz__field" role="application" aria-label="어트랙터 지도책 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">2차원 점화식 · 채널당 2만 번</span>
<span class="lz__meta lz__meta--dim">canvas · 명암 산포 · 주기 2초</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 어트랙터 지도책(04) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(클리포드·드용 점화식 반복 → 방문 밝기 누적 → 채널 순차 합성)가 만든다.
// 무대는 편 공통 심해 남색. 채널 3색은 파스텔 트리오(라벤더·아이스·피치).
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-k0: #e6d9ff; // 채널 0 — 라일락 화이트
$lz-k1: #cfe3ff; // 채널 1 — 얼음 파스텔
$lz-k2: #ffd9c9; // 채널 2 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-k0;
.lz {
--lz-panel: #{$lz-panel};
--lz-k0: #{$lz-k0};
--lz-k1: #{$lz-k1};
--lz-k2: #{$lz-k2};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-k0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-k0: #e6d9ff;
--lz-k1: #cfe3ff;
--lz-k2: #ffd9c9;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #e6d9ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 어트랙터 지도책(04) — 로렌츠와 같은 '어트랙터' 개념을 2차원 점화식으로 보여준다.
// 클리포드 x' = sin(a·y) + c·cos(a·x), y' = sin(b·x) + d·cos(b·y)
// 드용 x' = sin(a·y) − b·cos(x), y' = sin(c·x) − d·cos(y)
// 한 점을 식에 반복 통과시키며 ImageData 채널에 방문 횟수를 누적한다.
// 하나의 궤적이 채널을 채우면 다음 채널로 넘어가고, 셋이 다 채워지면 색으로 합성된다.
// 무작위 시드가 루프마다 같은 결정론 궤도를 그리므로 화면은 매 루프 재현 가능하다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var ITERS = 20000; // 채널당 반복 수 — 늘리면 문양이 더 촘촘해진다
var CYCLE = 2.0; // 완성 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 COLS = ['--lz-k0', '--lz-k1', '--lz-k2'].map(tokRGB); // 채널 3색 — 파스텔 트리오
var PANEL = tokRGB('--lz-panel');
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 계수 목록 — 클리포드 a,b,c,d / 드용 a,b,c,d (뒤의 3개는 드용)
var SETS = [
{ kind: 0, p: [-1.4, 1.6, 1.0, 0.7] },
{ kind: 0, p: [-1.7, 1.3, 0.5, 1.21] },
{ kind: 0, p: [1.5, -1.8, 1.6, 0.9] },
{ kind: 1, p: [2.01, -2.53, 1.61, -0.33] },
{ kind: 1, p: [-2.7, -0.09, -0.86, -2.2] },
{ kind: 1, p: [-2.24, 0.43, -0.65, -2.43] }
];
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var RES = 150; // 내부 누적 해상도(정사각) — 성능 노브
var acc; // 방문 카운트 Float32Array(RES*RES)
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
acc = new Float32Array(RES * RES);
}
// 결정론 난수 — 매 루프 같은 그림이 쌓이게 하는 시드 고정 mulberry32
function rng(seed) {
var t = seed;
return function () {
t += 0x6D2B79F5;
var r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
// 한 채널 누적 — kind 0=클리포드, 1=드용. 걸음 수 비율 upto 까지.
function accumulate(set, upto) {
var rand = rng(set.kind * 1000 + 7);
var x = rand() * 2 - 1, y = rand() * 2 - 1;
var n = Math.floor(ITERS * upto);
var a = set.p[0], b = set.p[1], c = set.p[2], d = set.p[3];
var i, px, py;
for (i = 0; i < n + 400; i++) {
if (set.kind === 0) {
var nx = Math.sin(a * y) + c * Math.cos(a * x);
y = Math.sin(b * x) + d * Math.cos(b * y);
x = nx;
} else {
var nx2 = Math.sin(a * y) - b * Math.cos(x);
y = Math.sin(c * x) - d * Math.cos(y);
x = nx2;
}
if (i < 400) continue; // 과도기 버림
px = (x + 3) / 6 * (RES - 1);
py = (y + 3) / 6 * (RES - 1);
if (px >= 0 && px < RES && py >= 0 && py < RES) {
acc[(py | 0) * RES + (px | 0)] += 1;
}
}
}
// 누적 카운트 → 명암 산포(반비례 감마)로 그리기 — 채널색 3장 합성
function blit(phase) {
var maxA = 0, i;
for (i = 0; i < acc.length; i++) if (acc[i] > maxA) maxA = acc[i];
if (maxA <= 0) maxA = 1;
// 채널 진행 — 셋으로 나눠 순차 등장
var seg = phase * 3;
var chAmt = [Math.max(0, Math.min(1, seg)), Math.max(0, Math.min(1, seg - 1)), Math.max(0, Math.min(1, seg - 2))];
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.92;
var ox = (W - side) / 2, oy = (H - side) / 2;
var cell = side / RES;
var g, alpha, cx, cy;
ctx.save();
ctx.translate(ox, oy);
for (var j = 0; j < RES; j++) {
cy = j * cell;
for (var k2 = 0; k2 < RES; k2++) {
var v = acc[j * RES + k2];
if (v <= 0) continue;
g = Math.pow(v / maxA, 0.32); // 감마 — 옅은 방문도 살린다
// 채널 3개를 합성 — ch0 파스텔 라벤더, ch1 아이스, ch2 피치
var r0 = 0, g0 = 0, b0 = 0, kk;
for (kk = 0; kk < 3; kk++) {
if (chAmt[kk] <= 0) continue;
var w = g * chAmt[kk] / 3;
r0 += COLS[kk][0] * w; g0 += COLS[kk][1] * w; b0 += COLS[kk][2] * w;
}
alpha = Math.min(1, 0.16 + g * 0.9);
ctx.fillStyle = 'rgba(' + (r0 | 0) + ',' + (g0 | 0) + ',' + (b0 | 0) + ',' + alpha + ')';
cx = k2 * cell;
ctx.fillRect(cx, cy, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
}
// 채널 0..2 = 지도책 서로 다른 계수 — 세 채널이 같은 판에 겹쳐 합성된다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, lastSeg = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
var seg = Math.floor(phase * 3);
if (seg !== lastSeg) { // 채널이 넘어갈 때만 누적을 다시 쌓는다
lastSeg = seg;
acc = new Float32Array(RES * RES);
accumulate(SETS[seg], 1);
accumulate(SETS[seg + 3], 1);
}
blit(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) blit(0.9);
});
fit();
if (reduce) {
lastSeg = 0;
accumulate(SETS[0], 1); accumulate(SETS[3], 1);
blit(0.9); // reduce — 완성 문양 한 장
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzAtlasCliffordDejong — vanilla 판(run/324/demo/04-lorenz-atlas-clifford-dejong/index.html) 의 React 포트.
// 클리포드 x' = sin(a·y) + c·cos(a·x), y' = sin(b·x) + d·cos(b·y)
// 드용 x' = sin(a·y) − b·cos(x), y' = sin(c·x) − d·cos(y)
// 한 점을 식에 반복 통과시키며 방문 횟수를 누적한다.
// 하나의 궤적이 채널을 채우면 다음 채널로 넘어가고, 셋이 다 채워지면 색으로 합성된다.
// 무작위 시드가 루프마다 같은 결정론 궤도를 그리므로 화면은 매 루프 재현 가능하다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzAtlasCliffordDejong.module.scss";
export type LorenzAtlasCliffordDejongProps = {
duration?: string;
easing?: string;
color?: string;
iters?: number; // 채널당 반복 수 — 늘리면 문양이 더 촘촘해진다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const ITERS = 20000; // 채널당 반복 수
const CYCLE = 2.0; // 완성 주기(초) — 설정 초(2s)와 같다
const RES = 150; // 내부 누적 해상도(정사각) — 성능 노브
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type AttractorSet = { kind: number; p: [number, number, number, number] };
const FIELD_LABEL = "어트랙터 지도책 실험판";
const META_LEFT = "2차원 점화식 · 채널당 2만 번";
const META_RIGHT = "canvas · 명암 산포 · 주기 2초";
const HINT = "클리포드·드용 점화식에 수만 번의 반복을 흘려보내 방문 밝기를 쌓으면 계수 조합마다 다른 문양이 펼쳐집니다";
// 계수 목록 — 클리포드 a,b,c,d / 드용 a,b,c,d (뒤의 3개는 드용)
const SETS: AttractorSet[] = [
{ kind: 0, p: [-1.4, 1.6, 1.0, 0.7] },
{ kind: 0, p: [-1.7, 1.3, 0.5, 1.21] },
{ kind: 0, p: [1.5, -1.8, 1.6, 0.9] },
{ kind: 1, p: [2.01, -2.53, 1.61, -0.33] },
{ kind: 1, p: [-2.7, -0.09, -0.86, -2.2] },
{ kind: 1, p: [-2.24, 0.43, -0.65, -2.43] }
];
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
// 결정론 난수 — 매 루프 같은 그림이 쌓이게 하는 시드 고정 mulberry32
function rng(seed: number): () => number {
let t = seed;
return function (): number {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
export default function LorenzAtlasCliffordDejong({
duration = "2s",
easing = "ease-out",
color = "#e6d9ff",
iters = ITERS
}: LorenzAtlasCliffordDejongProps) {
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 colsRef = useRef<RGB[] | null>(null);
const panelRef = useRef<RGB | null>(null);
const accRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const lastSegRef = useRef(-1) as Box<number>;
const itersRef = useRef(iters) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
accRef.current = new Float32Array(RES * RES);
}
// 한 채널 누적 — kind 0=클리포드, 1=드용. 걸음 수 비율 upto 까지.
function accumulate(set: AttractorSet, upto: number): void {
const acc = accRef.current;
if (!acc) return;
const rand = rng(set.kind * 1000 + 7);
let x = rand() * 2 - 1, y = rand() * 2 - 1;
const n = Math.floor(itersRef.current * upto);
const a = set.p[0], b = set.p[1], c = set.p[2], d = set.p[3];
for (let i = 0; i < n + 400; i++) {
if (set.kind === 0) {
const nx = Math.sin(a * y) + c * Math.cos(a * x);
y = Math.sin(b * x) + d * Math.cos(b * y);
x = nx;
} else {
const nx2 = Math.sin(a * y) - b * Math.cos(x);
y = Math.sin(c * x) - d * Math.cos(y);
x = nx2;
}
if (i < 400) continue; // 과도기 버림
const px = (x + 3) / 6 * (RES - 1);
const py = (y + 3) / 6 * (RES - 1);
if (px >= 0 && px < RES && py >= 0 && py < RES) {
acc[(py | 0) * RES + (px | 0)] += 1;
}
}
}
// 누적 카운트 → 명암 산포(반비례 감마)로 그리기 — 채널색 3장 합성
function blit(phase: number): void {
const ctx = ctxRef.current;
const acc = accRef.current;
const COLS = colsRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !acc || !COLS || !PANEL) return;
let maxA = 0;
for (let i = 0; i < acc.length; i++) if (acc[i] > maxA) maxA = acc[i];
if (maxA <= 0) maxA = 1;
// 채널 진행 — 셋으로 나눠 순차 등장
const seg = phase * 3;
const chAmt = [
Math.max(0, Math.min(1, seg)),
Math.max(0, Math.min(1, seg - 1)),
Math.max(0, Math.min(1, seg - 2))
];
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.92;
const ox = (W - side) / 2, oy = (H - side) / 2;
const cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
for (let j = 0; j < RES; j++) {
const cy = j * cell;
for (let k2 = 0; k2 < RES; k2++) {
const v = acc[j * RES + k2];
if (v <= 0) continue;
const g = Math.pow(v / maxA, 0.32); // 감마 — 옅은 방문도 살린다
// 채널 3개를 합성 — ch0 파스텔 라벤더, ch1 아이스, ch2 피치
let r0 = 0, g0 = 0, b0 = 0;
for (let kk = 0; kk < 3; kk++) {
if (chAmt[kk] <= 0) continue;
const w = g * chAmt[kk] / 3;
r0 += COLS[kk][0] * w; g0 += COLS[kk][1] * w; b0 += COLS[kk][2] * w;
}
const alpha = Math.min(1, 0.16 + g * 0.9);
ctx.fillStyle = "rgba(" + (r0 | 0) + "," + (g0 | 0) + "," + (b0 | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cell, cy, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
const seg = Math.floor(phase * 3);
if (seg !== lastSegRef.current) { // 채널이 넘어갈 때만 누적을 다시 쌓는다
lastSegRef.current = seg;
accRef.current = new Float32Array(RES * RES);
accumulate(SETS[seg], 1);
accumulate(SETS[seg + 3], 1);
}
blit(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
itersRef.current = iters;
// 채널 3색 — 파스텔 트리오
colsRef.current = ["--lz-k0", "--lz-k1", "--lz-k2"].map((nm) => tokRGB(root, nm));
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) blit(0.9);
}
window.addEventListener("resize", onResize);
if (reduced()) {
lastSegRef.current = 0;
accumulate(SETS[0], 1);
accumulate(SETS[3], 1);
blit(0.9); // reduce — 완성 문양 한 장
setStill(true);
} else {
t0 = -1;
lastSegRef.current = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [iters]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}04 · 어트랙터 지도책 — 클리포드·드용, 수백만 점으로 그리는 확률 꽃 — 측정 누적면적 8.677% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
미분방정식이 아닌 2차원 점화식 — 클리포드 x′=sin(a·y)+c·cos(a·x) · y′=sin(b·x)+d·cos(b·y), 드용 x′=sin(a·y)−b·cos(x) · y′=sin(c·x)−d·cos(y) — 의 계수 조합 6벌(클리포드 3 · 드용 3)을 시드 고정 난수로 채널당 2만 번 반복해 방문 카운트를 150px 누적 버퍼에 쌓고, 3색 채널을 2초 주기로 순차 등장시킵니다. 처음 400걸음 과도기는 버려 문양을 또렷하게 하는데, 측정은 누적면적 8.677% · 강도평균 15.5 · 움직인프레임 19(순차 등장 구조상 멈추는 프레임이 있음)입니다.
한 채널을 쌓는 누적 루프
function accumulate(set, upto) {
var rand = rng(set.kind * 1000 + 7);
var x = rand() * 2 - 1, y = rand() * 2 - 1;
var n = Math.floor(ITERS * upto);
var a = set.p[0], b = set.p[1], c = set.p[2], d = set.p[3];
var i, px, py;
for (i = 0; i < n + 400; i++) {
if (set.kind === 0) {
var nx = Math.sin(a * y) + c * Math.cos(a * x);
y = Math.sin(b * x) + d * Math.cos(b * y);
x = nx;
} else {
var nx2 = Math.sin(a * y) - b * Math.cos(x);
y = Math.sin(c * x) - d * Math.cos(y);
x = nx2;
}
if (i < 400) continue; // 과도기 버림
px = (x + 3) / 6 * (RES - 1);
py = (y + 3) / 6 * (RES - 1);
if (px >= 0 && px < RES && py >= 0 && py < RES) {
acc[(py | 0) * RES + (px | 0)] += 1;
}
}
}
05아이자와·토머스 — 구면과 나선, 다른 방정식이 주는 다른 조각

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>계수 몇 개를 바꾼 또 다른 미분방정식은 구면을 감도는 아이자와와 실뭉치 나선의 토머스를 그립니다</span>
</p>
<div class="lz__field" role="application" aria-label="아이자와·토머스 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">좌 아이자와 · 우 토머스 · RK4</span>
<span class="lz__meta lz__meta--dim">canvas · 깊이 음영 · 주기 2초</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 아이자와·토머스(05) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(두 미분방정식을 RK4 로 적분해 나란히 드러낸다)가 만든다.
// 무대는 편 공통 심해 남색. 아이자와=라일락 화이트, 토머스=민트 크림.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-ca: #e6d9ff; // 아이자와 궤적 — 라일락 화이트
$lz-ct: #d8f0e3; // 토머스 궤적 — 민트 크림
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-ca;
.lz {
--lz-panel: #{$lz-panel};
--lz-ca: #{$lz-ca};
--lz-ct: #{$lz-ct};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-ca; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-ca: #e6d9ff;
--lz-ct: #d8f0e3;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #e6d9ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 아이자와·토머스(05) — 로렌츠가 아닌 어트랙터 두 개를 나란히 보여준다.
// 아이자와(구면): dx = (z−b)x − d·y, dy = d·x + (z−b)y, dz = c + a·z − z³/3 − (x²+y²)(1+e·z) + f·z·x³
// 토머스(나선): dx = sin(y) − b·x, dy = sin(z) − b·y, dz = sin(x) − b·z
// 두 계통 모두 RK4 로 적분하고, 관찰축을 천천히 돌려 입체 형태를 보여준다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.02; // RK4 걸음
var STEPS = 1800; // 각 판의 사전 적분 걸음 수
var SPIN_S = 2.0; // 궤적 드러나기 주기(초) — 설정 초(2s)와 같다
var SPIN_L = 12.0; // 관찰축 회전 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 CA = tokRGB('--lz-ca'); // 아이자와 — 라일락 화이트
var CT = tokRGB('--lz-ct'); // 토머스 — 민트 크림
var HEAD = tokRGB('--lz-head'); // 머리 — 크림(토큰)
var PANEL = tokRGB('--lz-panel');
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 아이자와 계수 — a=0.95, b=0.7, c=0.6, d=3.5, e=0.25, f=0.1 의 대표 형태
function dAizawa(x, y, z) {
var a = 0.95, b = 0.7, c = 0.6, d = 3.5, e = 0.25, f = 0.1;
return [
(z - b) * x - d * y,
d * x + (z - b) * y,
c + a * z - z * z * z / 3 - (x * x + y * y) * (1 + e * z) + f * z * x * x * x
];
}
// 토머스 계수 b=0.19 — 주기적으로 겹는 나선
function dThomas(x, y, z) {
var b = 0.19;
return [Math.sin(y) - b * x, Math.sin(z) - b * y, Math.sin(x) - b * z];
}
function rk4(p, f) {
var k1 = f(p[0], p[1], p[2]);
var k2 = f(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = f(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = f(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
// 사전 적분 — 아이자와는 구 중심 근처, 토머스는 원점에서
var azX = new Float32Array(STEPS), azY = new Float32Array(STEPS), azZ = new Float32Array(STEPS);
var thX = new Float32Array(STEPS), thY = new Float32Array(STEPS), thZ = new Float32Array(STEPS);
var pa = [0.1, 0, 0], pt = [1.1, 1.2, -0.6], i;
for (i = 0; i < 100; i++) { pa = rk4(pa, dAizawa); pt = rk4(pt, dThomas); }
for (i = 0; i < STEPS; i++) {
azX[i] = pa[0]; azY[i] = pa[1]; azZ[i] = pa[2]; pa = rk4(pa, dAizawa);
thX[i] = pt[0]; thY[i] = pt[1]; thZ[i] = pt[2]; pt = rk4(pt, dThomas);
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
var REVEAL_END = 0.82;
// 한 판 그리기 — xz 투영 + y 깊이 음영. xz: x 가로, z 세로, y 를 사선 성분으로
function drawPane(X, Y, Z, cx, cy, s, col, n, fade, tilt) {
if (n < 2) return;
var i2, hx, hy;
// 뒤네루게
ctx.strokeStyle = rgba(col, 0.12 * fade);
ctx.lineWidth = 3.0;
ctx.beginPath();
hx = cx + (X[0] + Y[0] * tilt) * s; hy = cy - Z[0] * s;
ctx.moveTo(hx, hy);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + (X[i2] + Y[i2] * tilt) * s, cy - Z[i2] * s);
ctx.stroke();
// 알맹이 — 걸음 진행에 따라 밝아지는 램프
var ramp = Math.max(0.10, 0.5 * (1 - n / STEPS) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(cx + (X[0] + Y[0] * tilt) * s, cy - Z[0] * s);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + (X[i2] + Y[i2] * tilt) * s, cy - Z[i2] * s);
ctx.stroke();
// 머리
hx = cx + (X[n - 1] + Y[n - 1] * tilt) * s; hy = cy - Z[n - 1] * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase, tSec) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var upto = Math.min(1, phase / REVEAL_END);
var n = Math.floor(upto * STEPS);
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 관찰축 회전 — 사선 성분 tilt 를 진동시킨다
var tilt = Math.sin(tSec / SPIN_L * 6.2832) * 0.5;
var mid = W / 2;
ctx.strokeStyle = rgba(CA, 0.14);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(azX, azY, azZ, mid * 0.52, H * 0.52, Math.min(W / 46, H / 42) * 0.6, CA, n, fade, tilt);
drawPane(thX, thY, thZ, mid * 1.48, H * 0.5, Math.min(W / 46, H / 42) * 0.5, CT, n, fade, tilt);
ctx.textAlign = 'center';
ctx.fillStyle = rgba(CA, 0.9 * fade + 0.1);
ctx.fillText('AIZAWA', mid * 0.52, H - 6);
ctx.fillStyle = rgba(CT, 0.9 * fade + 0.1);
ctx.fillText('THOMAS', mid * 1.48, H - 6);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var tSec = (now - t0) / 1000;
draw((tSec % SPIN_S) / SPIN_S, tSec);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62, 0.4);
});
fit();
if (reduce) {
draw(0.62, 0.4);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzAtlasAizawaThomas — vanilla 판(run/324/demo/05-lorenz-atlas-aizawa-thomas/index.html) 의 React 포트.
// 아이자와(구면): dx = (z−b)x − d·y, dy = d·x + (z−b)y, dz = c + a·z − z³/3 − (x²+y²)(1+e·z) + f·z·x³
// 토머스(나선): dx = sin(y) − b·x, dy = sin(z) − b·y, dz = sin(x) − b·z
// 두 계통 모두 RK4 로 적분하고, 관찰축을 천천히 돌려 입체 형태를 보여준다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzAtlasAizawaThomas.module.scss";
export type LorenzAtlasAizawaThomasProps = {
duration?: string;
easing?: string;
color?: string;
steps?: number; // 각 판의 사전 적분 걸음 수
spinS?: number; // 궤적 드러나기 주기(초) — 설정 초(2s)와 같다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.02; // RK4 걸음
const STEPS = 1800; // 각 판의 사전 적분 걸음 수
const SPIN_S = 2.0; // 궤적 드러나기 주기(초) — 설정 초(2s)와 같다
const SPIN_L = 12.0; // 관찰축 회전 주기(초)
const REVEAL_END = 0.82;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Vec3 = [number, number, number];
type Deriv = (x: number, y: number, z: number) => Vec3;
const FIELD_LABEL = "아이자와·토머스 실험판";
const META_LEFT = "좌 아이자와 · 우 토머스 · RK4";
const META_RIGHT = "canvas · 깊이 음영 · 주기 2초";
const HINT = "계수 몇 개를 바꾼 또 다른 미분방정식은 구면을 감도는 아이자와와 실뭉치 나선의 토머스를 그립니다";
// 아이자와 계수 — a=0.95, b=0.7, c=0.6, d=3.5, e=0.25, f=0.1 의 대표 형태
function dAizawa(x: number, y: number, z: number): Vec3 {
const a = 0.95, b = 0.7, c = 0.6, d = 3.5, e = 0.25, f = 0.1;
return [
(z - b) * x - d * y,
d * x + (z - b) * y,
c + a * z - z * z * z / 3 - (x * x + y * y) * (1 + e * z) + f * z * x * x * x
];
}
// 토머스 계수 b=0.19 — 주기적으로 겹는 나선
function dThomas(x: number, y: number, z: number): Vec3 {
const b = 0.19;
return [Math.sin(y) - b * x, Math.sin(z) - b * y, Math.sin(x) - b * z];
}
function rk4(p: Vec3, f: Deriv): Vec3 {
const k1 = f(p[0], p[1], p[2]);
const k2 = f(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
const k3 = f(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
const k4 = f(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LorenzAtlasAizawaThomas({
duration = "2s",
easing = "ease-out",
color = "#e6d9ff",
steps = STEPS,
spinS = SPIN_S
}: LorenzAtlasAizawaThomasProps) {
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 caRef = useRef<RGB | null>(null);
const ctRef = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const azRef = useRef<{ x: Float32Array; y: Float32Array; z: Float32Array } | null>(null);
const thRef = useRef<{ x: Float32Array; y: Float32Array; z: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 사전 적분 — 아이자와는 구 중심 근처, 토머스는 원점에서
function precompute(n: number): void {
const azX = new Float32Array(n), azY = new Float32Array(n), azZ = new Float32Array(n);
const thX = new Float32Array(n), thY = new Float32Array(n), thZ = new Float32Array(n);
let pa: Vec3 = [0.1, 0, 0], pt: Vec3 = [1.1, 1.2, -0.6];
for (let i = 0; i < 100; i++) { pa = rk4(pa, dAizawa); pt = rk4(pt, dThomas); }
for (let i = 0; i < n; i++) {
azX[i] = pa[0]; azY[i] = pa[1]; azZ[i] = pa[2]; pa = rk4(pa, dAizawa);
thX[i] = pt[0]; thY[i] = pt[1]; thZ[i] = pt[2]; pt = rk4(pt, dThomas);
}
azRef.current = { x: azX, y: azY, z: azZ };
thRef.current = { x: thX, y: thY, z: thZ };
}
// 한 판 그리기 — xz 투영 + y 깊이 음영. xz: x 가로, z 세로, y 를 사선 성분으로
function drawPane(
X: Float32Array, Y: Float32Array, Z: Float32Array,
cx: number, cy: number, s: number, col: RGB, head: RGB, n: number, fade: number, tilt: number,
ctx: CanvasRenderingContext2D
): void {
if (n < 2) return;
// 뒤네루게
ctx.strokeStyle = rgba(col, 0.12 * fade);
ctx.lineWidth = 3.0;
ctx.beginPath();
let hx = cx + (X[0] + Y[0] * tilt) * s, hy = cy - Z[0] * s;
ctx.moveTo(hx, hy);
for (let i = 1; i < n; i++) ctx.lineTo(cx + (X[i] + Y[i] * tilt) * s, cy - Z[i] * s);
ctx.stroke();
// 알맹이 — 걸음 진행에 따라 밝아지는 램프
const ramp = Math.max(0.10, 0.5 * (1 - n / steps) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(cx + (X[0] + Y[0] * tilt) * s, cy - Z[0] * s);
for (let i = 1; i < n; i++) ctx.lineTo(cx + (X[i] + Y[i] * tilt) * s, cy - Z[i] * s);
ctx.stroke();
// 머리
hx = cx + (X[n - 1] + Y[n - 1] * tilt) * s; hy = cy - Z[n - 1] * s;
ctx.fillStyle = rgba(head, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(head, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase: number, tSec: number): void {
const ctx = ctxRef.current;
const az = azRef.current;
const th = thRef.current;
const PANEL = panelRef.current;
const CA = caRef.current;
const CT = ctRef.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !az || !th || !PANEL || !CA || !CT || !HEAD) return;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const upto = Math.min(1, phase / REVEAL_END);
const n = Math.floor(upto * steps);
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 관찰축 회전 — 사선 성분 tilt 를 진동시킨다
const tilt = Math.sin(tSec / SPIN_L * 6.2832) * 0.5;
const mid = W / 2;
ctx.strokeStyle = rgba(CA, 0.14);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(az.x, az.y, az.z, mid * 0.52, H * 0.52, Math.min(W / 46, H / 42) * 0.6, CA, HEAD, n, fade, tilt, ctx);
drawPane(th.x, th.y, th.z, mid * 1.48, H * 0.5, Math.min(W / 46, H / 42) * 0.5, CT, HEAD, n, fade, tilt, ctx);
ctx.textAlign = "center";
ctx.fillStyle = rgba(CA, 0.9 * fade + 0.1);
ctx.fillText("AIZAWA", mid * 0.52, H - 6);
ctx.fillStyle = rgba(CT, 0.9 * fade + 0.1);
ctx.fillText("THOMAS", mid * 1.48, H - 6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const tSec = (now - t0) / 1000;
draw((tSec % spinS) / spinS, tSec);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
caRef.current = tokRGB(root, "--lz-ca"); // 아이자와 — 라일락 화이트
ctRef.current = tokRGB(root, "--lz-ct"); // 토머스 — 민트 크림
headRef.current = tokRGB(root, "--lz-head");// 머리 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
precompute(steps);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62, 0.4);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62, 0.4);
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
}, [steps, spinS]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}05 · 아이자와·토머스 — 구면과 나선, 다른 방정식이 주는 다른 조각 — 측정 누적면적 0.768% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
로렌츠가 아니어도 어트랙터는 무궁무진하다는 비교 실험으로, 왼쪽 아이자와는 구면을 감도는 궤적 · 오른쪽 토머스는 실뭉치 나선을 그립니다. 둘 다 RK4 dt 0.02 로 1,800걸음 적분하고 공개 2초 · 관찰축 회전 12초로 입체 형태를 보여주는데, 얇은 궤적 선 한 가닥으로 형태를 보여주는 성격이라 누적면적 0.768% · 강도평균 60.7 의 작은 면적이 자연스럽습니다.
아이자와와 토머스의 속도식
function dAizawa(x, y, z) {
var a = 0.95, b = 0.7, c = 0.6, d = 3.5, e = 0.25, f = 0.1;
return [
(z - b) * x - d * y,
d * x + (z - b) * y,
c + a * z - z * z * z / 3 - (x * x + y * y) * (1 + e * z) + f * z * x * x * x
];
}
// 토머스 계수 b=0.19 — 주기적으로 겹치는 나선
function dThomas(x, y, z) {
var b = 0.19;
return [Math.sin(y) - b * x, Math.sin(z) - b * y, Math.sin(x) - b * z];
}
06밀도 구름 — 방문 횟수를 픽셀 밝기로 쌓는 안개 질감

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>한 점 대신 수천 점을 동시에 풀어 픽셀의 통과 횟수를 밝기로 더하면 궤적이 안개 같은 밀도 장이 됩니다</span>
</p>
<div class="lz__field" role="application" aria-label="밀도 구름 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">동시 점 2,000 · 오일러 dt 0.016</span>
<span class="lz__meta lz__meta--dim">canvas · 히스토그램 · 주기 2초</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 밀도 구름(06) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(2,000점 동시 오일러 적분 → 히스토그램 누적 → 감마 램프)가 만든다.
// 무대는 편 공통 심해 남색. 밀도 낮은 쪽=얼음, 높은 쪽=복숭아 파스텔.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-cl: #cfe3ff; // 낮은 밀도 — 얼음 파스텔
$lz-ch: #ffd9c9; // 높은 밀도 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-cl;
.lz {
--lz-panel: #{$lz-panel};
--lz-cl: #{$lz-cl};
--lz-ch: #{$lz-ch};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-cl; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-cl: #cfe3ff;
--lz-ch: #ffd9c9;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 밀도 구름(06) — 로렌츠 세 식을 통계로 읽는다.
// 2,000개 점을 아주 작은 섭동(±0.01)을 준 같은 시작 영역에서 동시에 적분하고
// 저해상도 히스토그램 버퍼에 방문 횟수를 쌓는다. 매 프레임 버퍼를 감마 램프로
// 뿌리면 날개 내부 구조가 은하처럼 밝아지는 밀도 장이 된다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NPTS = 2000; // 동시 점 수 — 8,000 으로 올리면 안개가 짙어진다(변형 3)
var DT = 0.016; // 오일러 걸음 — 프레임당 2회 적분
var CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 CL = tokRGB('--lz-cl'); // 밀도 낮은 쪽 — 얼음 파스텔
var CH2 = tokRGB('--lz-ch'); // 밀도 높은 쪽 — 복숭아 파스텔
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
var RES = 150; // 히스토그램 해상도(정사각)
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var hist = new Float32Array(RES * RES);
var px = new Float32Array(NPTS), py = new Float32Array(NPTS), pz = new Float32Array(NPTS);
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
}
// 결정론 난수 — 리셋마다 같은 시작 뭉치가 되게 하는 시드 고정
function rng(seed) {
var t = seed;
return function () {
t += 0x6D2B79F5;
var r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
var rand = rng(42);
function seedPoints() {
var i;
for (i = 0; i < NPTS; i++) {
px[i] = 0.6 + (rand() - 0.5) * 0.02;
py[i] = 0.7 + (rand() - 0.5) * 0.02;
pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
// 오일러 적분 — 프레임당 2걸음씩 진행
function stepAll() {
var i, k, dx, dy, dz;
for (k = 0; k < 2; k++) {
for (i = 0; i < NPTS; i++) {
var x = px[i], y = py[i], z = pz[i];
dx = SIG * (y - x); dy = x * (RHO - z) - y; dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
// 히스토그램 버퍼에 방문 누적 — xz 투영
function accumulate() {
var i, gx, gy;
for (i = 0; i < NPTS; i++) {
gx = (px[i] / 22 + 0.5) * (RES - 1);
gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
// 버퍼 → 화면 — 감마 램프로 낮은 밀도도 살리고 최대 밀도를 복숭아로
function blit(fade) {
var maxA = 0, i;
for (i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
var ctx2 = cvEl.getContext('2d');
ctx2.fillStyle = 'rgb(' + PANEL[0] + ',' + PANEL[1] + ',' + PANEL[2] + ')';
ctx2.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.94;
var ox = (W - side) / 2, oy = (H - side) / 2;
var cell = side / RES;
ctx2.save();
ctx2.translate(ox, oy);
var j, k2, v, g, col, alpha;
for (j = 0; j < RES; j++) {
for (k2 = 0; k2 < RES; k2++) {
v = hist[j * RES + k2];
if (v <= 0) continue;
g = Math.pow(v / maxA, 0.38);
col = mix(CL, CH2, g);
alpha = Math.min(1, 0.10 + g * 0.85) * fade;
ctx2.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + alpha + ')';
ctx2.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx2.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
if (phase < 0.02 && hist[0] + hist[RES * RES - 1] >= 0 && (now - t0) > 60 && phaseResetDone === false) {
hist = new Float32Array(RES * RES);
seedPoints();
phaseResetDone = true;
}
if (phase > 0.05) phaseResetDone = false;
stepAll();
accumulate();
var fade = phase > 0.9 ? Math.max(0, 1 - (phase - 0.9) / 0.1) : 1;
blit(fade);
requestAnimationFrame(frame);
}
var phaseResetDone = false;
window.addEventListener('resize', function () {
fit();
if (reduce) blit(1);
});
fit();
seedPoints();
if (reduce) {
var i;
for (i = 0; i < 90; i++) { stepAll(); accumulate(); } // reduce — 쌓인 한 장
blit(1);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzVoxelDensityCloud — vanilla 판(run/324/demo/06-lorenz-voxel-density-cloud/index.html) 의 React 포트.
// 2,000개 점을 아주 작은 섭동(±0.01)을 준 같은 시작 영역에서 동시에 적분하고
// 저해상도 히스토그램 버퍼에 방문 횟수를 쌓는다. 매 프레임 버퍼를 감마 램프로
// 뿌리면 날개 내부 구조가 은하처럼 밝아지는 밀도 장이 된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzVoxelDensityCloud.module.scss";
export type LorenzVoxelDensityCloudProps = {
duration?: string;
easing?: string;
color?: string;
npts?: number; // 동시 점 수 — 8,000 으로 올리면 안개가 짙어진다(변형 3)
dt?: number; // 오일러 걸음 — 프레임당 2회 적분
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NPTS = 2000; // 동시 점 수
const DT = 0.016; // 오일러 걸음
const CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
const RES = 150; // 히스토그램 해상도(정사각)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "밀도 구름 실험판";
const META_LEFT = "동시 점 2,000 · 오일러 dt 0.016";
const META_RIGHT = "canvas · 히스토그램 · 주기 2초";
const HINT = "한 점 대신 수천 점을 동시에 풀어 픽셀의 통과 횟수를 밝기로 더하면 궤적이 안개 같은 밀도 장이 됩니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
// 결정론 난수 — 리셋마다 같은 시작 뭉치가 되게 하는 시드 고정
function rng(seed: number): () => number {
let t = seed;
return function (): number {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
export default function LorenzVoxelDensityCloud({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
npts = NPTS,
dt = DT
}: LorenzVoxelDensityCloudProps) {
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 clRef = useRef<RGB | null>(null); // 밀도 낮은 쪽 — 얼음 파스텔
const chRef = useRef<RGB | null>(null); // 밀도 높은 쪽 — 복숭아 파스텔
const panelRef = useRef<RGB | null>(null);
const histRef = useRef<Float32Array | null>(null);
const ptsRef = useRef<{ px: Float32Array; py: Float32Array; pz: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function allocPoints(n: number): void {
ptsRef.current = {
px: new Float32Array(n),
py: new Float32Array(n),
pz: new Float32Array(n)
};
}
function seedPoints(): void {
const pts = ptsRef.current;
if (!pts) return;
const rand = rng(42);
for (let i = 0; i < pts.px.length; i++) {
pts.px[i] = 0.6 + (rand() - 0.5) * 0.02;
pts.py[i] = 0.7 + (rand() - 0.5) * 0.02;
pts.pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
// 오일러 적분 — 프레임당 2걸음씩 진행
function stepAll(step: number): void {
const pts = ptsRef.current;
if (!pts) return;
const { px, py, pz } = pts;
const N = px.length;
for (let k = 0; k < 2; k++) {
for (let i = 0; i < N; i++) {
const x = px[i], y = py[i], z = pz[i];
const dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
px[i] = x + dx * step; py[i] = y + dy * step; pz[i] = z + dz * step;
}
}
}
// 히스토그램 버퍼에 방문 누적 — xz 투영
function accumulate(): void {
const pts = ptsRef.current;
const hist = histRef.current;
if (!pts || !hist) return;
const { px, py, pz } = pts;
for (let i = 0; i < px.length; i++) {
const gx = (px[i] / 22 + 0.5) * (RES - 1);
const gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
// 버퍼 → 화면 — 감마 램프로 낮은 밀도도 살리고 최대 밀도를 복숭아로
function blit(fade: number): void {
const ctx = ctxRef.current;
const hist = histRef.current;
const CL = clRef.current;
const CH2 = chRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !hist || !CL || !CH2 || !PANEL) return;
let maxA = 0;
for (let i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
ctx.fillStyle = "rgb(" + PANEL[0] + "," + PANEL[1] + "," + PANEL[2] + ")";
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.94;
const ox = (W - side) / 2, oy = (H - side) / 2;
const cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
for (let j = 0; j < RES; j++) {
for (let k2 = 0; k2 < RES; k2++) {
const v = hist[j * RES + k2];
if (v <= 0) continue;
const g = Math.pow(v / maxA, 0.38);
const col = mix(CL, CH2, g);
const alpha = Math.min(1, 0.10 + g * 0.85) * fade;
ctx.fillStyle = "rgba(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
}
let t0 = -1;
let phaseResetDone = false;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
const hist = histRef.current;
if (phase < 0.02 && hist && hist[0] + hist[RES * RES - 1] >= 0 && (now - t0) > 60 && phaseResetDone === false) {
histRef.current = new Float32Array(RES * RES);
seedPoints();
phaseResetDone = true;
}
if (phase > 0.05) phaseResetDone = false;
stepAll(dt);
accumulate();
const fade = phase > 0.9 ? Math.max(0, 1 - (phase - 0.9) / 0.1) : 1;
blit(fade);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
clRef.current = tokRGB(root, "--lz-cl");
chRef.current = tokRGB(root, "--lz-ch");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
histRef.current = new Float32Array(RES * RES);
allocPoints(npts);
fit();
seedPoints();
function onResize(): void {
fit();
if (reduced()) blit(1);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let i = 0; i < 90; i++) { stepAll(dt); accumulate(); } // reduce — 쌓인 한 장
blit(1);
setStill(true);
} else {
t0 = -1;
phaseResetDone = false;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [npts, dt]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 · 밀도 구름 — 방문 횟수를 픽셀 밝기로 쌓는 안개 질감 — 측정 누적면적 1.04% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
한 점이 아니라 시드 42 로 뿌린 2,000점을 동시에 풀어 오일러 dt 0.016 · 프레임당 2걸음씩 진행하고, xz 투영의 방문 횟수를 150px 히스토그램에 누적하면 궤적 선이 안개 같은 밀도 장으로 바뀝니다. 2초 주기로 리셋한 뒤 다시 쌓는데, 히스토그램의 희박함이 성격이라 누적면적 1.04% · 강도평균 72.7 입니다.
2,000점을 동시에 밀어 주는 루프
function stepAll() {
var i, k, dx, dy, dz;
for (k = 0; k < 2; k++) {
for (i = 0; i < NPTS; i++) {
var x = px[i], y = py[i], z = pz[i];
dx = SIG * (y - x); dy = x * (RHO - z) - y; dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
07푸앵카레 단면 — 궤적이 평면을 지나는 순간만 찍는다

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>궤적이 기준 평면을 통과하는 순간의 좌표에만 점을 찍으면 날개마다 점 구름이 띠를 이룹니다</span>
</p>
<div class="lz__field" role="application" aria-label="푸앵카레 단면 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">z = 27 단면 · 양방향 통과 수집</span>
<span class="lz__meta lz__meta--dim">canvas · 단면 지도 · 주기 2초</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 푸앵카레 단면(07) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(z=27 평면 통과 순간만 모아 점 구름을 쌓는다)가 만든다.
// 무대는 편 공통 심해 남색. 상승 통과=얼음, 하강 통과=복숭아 파스텔.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-cu: #cfe3ff; // 상승 통과 점 — 얼음 파스텔
$lz-cd: #ffd9c9; // 하강 통과 점 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-cu;
.lz {
--lz-panel: #{$lz-panel};
--lz-cu: #{$lz-cu};
--lz-cd: #{$lz-cd};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-cu; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-cu: #cfe3ff;
--lz-cd: #ffd9c9;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 푸앵카레 단면(07) — 3차원 궤적을 2차원 지도로 압축한다.
// RK4 로 긴 궤적을 사전 적분해 두고, z 가 기준값 27 을 건너는 순간만 선형 보간으로
// (x, y) 를 찍는다. 상승 통과는 얼음, 하강 통과는 복숭아 — 두 방향이 각 날개에 띠를 만든다.
// 점은 저해상도 히스토그램에 3x3 스플랏으로 누적해 감마 램프로 뿌린다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.005; // RK4 걸음 — 작게 잡아 통과 순간을 정밀하게 잡는다
var STEPS = 14000; // 사전 적분 걸음 수 (총 70 시간단위)
var CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var Z_PLANE = 27; // 기준 단면 — z = 27
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 CU = tokRGB('--lz-cu'); // 상승 통과 — 얼음 파스텔
var CD = tokRGB('--lz-cd'); // 하강 통과 — 복숭아 파스텔
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rk4(p) {
var k1 = deriv(p[0], p[1], p[2]);
var k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
// 사전 적분 — 과도기 400걸음 버림
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var p = [0.6, 0.7, 21], i;
for (i = 0; i < 400; i++) p = rk4(p);
for (i = 0; i < STEPS; i++) {
xs[i] = p[0]; ys[i] = p[1]; zs[i] = p[2];
p = rk4(p);
}
// 통과 목록 — z 가 27 을 건너는 순간의 보간 (x, y) 와 방향(1 상승 / 0 하강)
var crX = [], crY = [], crUp = [];
for (i = 1; i < STEPS; i++) {
var za = zs[i - 1] - Z_PLANE, zb = zs[i] - Z_PLANE;
if ((za < 0 && zb >= 0) || (za >= 0 && zb < 0)) {
var t2 = za / (za - zb);
crX.push(xs[i - 1] + (xs[i] - xs[i - 1]) * t2);
crY.push(ys[i - 1] + (ys[i] - ys[i - 1]) * t2);
crUp.push(zb > za ? 1 : 0);
}
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var RES = 150; // 단면 지도 해상도(정사각)
var histU = new Float32Array(RES * RES);
var histD = new Float32Array(RES * RES);
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
// 통과 하나를 3x3 가우시안 근사 스플랏으로 누적
var W9 = [0.30, 0.55, 0.30, 0.55, 1.0, 0.55, 0.30, 0.55, 0.30];
function splat(cx2, cy2, up) {
var gx = (cx2 / 24 + 0.5) * (RES - 1);
var gy = (1 - (cy2 + 28) / 56) * (RES - 1);
var gx0 = Math.round(gx), gy0 = Math.round(gy), j, k2, w;
var dst = up ? histU : histD;
for (j = -1; j <= 1; j++) {
for (k2 = -1; k2 <= 1; k2++) {
var ix = gx0 + k2, iy = gy0 + j;
if (ix < 0 || ix >= RES || iy < 0 || iy >= RES) continue;
w = W9[(j + 1) * 3 + (k2 + 1)];
dst[iy * RES + ix] += w;
}
}
}
var REVEAL_END = 0.84;
function splatUpTo(count) {
var i2;
for (i2 = 0; i2 < count; i2++) splat(crX[i2], crY[i2], crUp[i2]);
}
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var upto = Math.min(1, phase / REVEAL_END);
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 배경 — xy 투영 전체 궤적이 순차 공개되며 단면의 맥락을 깐다
var s = Math.min(W / 50, H / 58);
var n = Math.floor(upto * STEPS);
if (n > 2) {
ctx.strokeStyle = rgba(CU, 0.09 * fade);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W / 2 + xs[0] * s, H / 2 - ys[0] * s);
for (i = 1; i < n; i++) ctx.lineTo(W / 2 + xs[i] * s, H / 2 - ys[i] * s);
ctx.stroke();
}
// 단면 지도 — 두 방향 히스토그램을 감마 램프로 합성
var maxU = 0, maxD = 0, i3;
for (i3 = 0; i3 < histU.length; i3++) {
if (histU[i3] > maxU) maxU = histU[i3];
if (histD[i3] > maxD) maxD = histD[i3];
}
if (maxU <= 0) maxU = 1;
if (maxD <= 0) maxD = 1;
var sideW = W * 0.44, sideH = H * 0.9;
var cellW = sideW / RES, cellH = sideH / RES;
ctx.save();
ctx.translate((W - sideW) / 2, (H - sideH) / 2);
var j, k2, su, sd, tot, col, alpha;
for (j = 0; j < RES; j++) {
for (k2 = 0; k2 < RES; k2++) {
su = Math.pow(histU[j * RES + k2] / maxU, 0.34);
sd = Math.pow(histD[j * RES + k2] / maxD, 0.34);
tot = su + sd;
if (tot <= 0.003) continue;
col = mix(CU, CD, sd / tot);
alpha = Math.min(1, 0.12 + tot * 0.85) * fade;
ctx.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + alpha + ')';
ctx.fillRect(k2 * cellW, j * cellH, cellW + 0.5, cellH + 0.5);
}
}
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, splatted = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
var want = Math.floor(Math.min(1, phase / REVEAL_END) * crX.length);
if (want !== splatted) { // 통과 수가 변할 때만 다시 스플랏
histU = new Float32Array(RES * RES);
histD = new Float32Array(RES * RES);
splatUpTo(want);
splatted = want;
}
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.8);
});
fit();
if (reduce) {
splatUpTo(crX.length); // reduce — 다 찬 단면 지도 한 장
draw(0.8);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzPoincareSectionSlice — vanilla 판(run/324/demo/07-lorenz-poincare-section-slice/index.html) 의 React 포트.
// RK4 로 긴 궤적을 사전 적분해 두고, z 가 기준값 27 을 건너는 순간만 선형 보간으로
// (x, y) 를 찍는다. 상승 통과는 얼음, 하강 통과는 복숭아 — 두 방향이 각 날개에 띠를 만든다.
// 점은 저해상도 히스토그램에 3x3 스플랏으로 누적해 감마 램프로 뿌린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzPoincareSectionSlice.module.scss";
export type LorenzPoincareSectionSliceProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // RK4 걸음 — 작게 잡아 통과 순간을 정밀하게 잡는다
steps?: number; // 사전 적분 걸음 수 (총 70 시간단위)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.005; // RK4 걸음
const STEPS = 14000; // 사전 적분 걸음 수 (총 70 시간단위)
const CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
const RES = 150; // 단면 지도 해상도(정사각)
const REVEAL_END = 0.84;
const Z_PLANE = 27; // 기준 단면 — z = 27
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Vec3 = [number, number, number];
const FIELD_LABEL = "푸앵카레 단면 실험판";
const META_LEFT = "z = 27 단면 · 양방향 통과 수집";
const META_RIGHT = "canvas · 단면 지도 · 주기 2초";
const HINT = "궤적이 기준 평면을 통과하는 순간의 좌표에만 점을 찍으면 날개마다 점 구름이 띠를 이룹니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function deriv(x: number, y: number, z: number): Vec3 {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rk4(p: Vec3, DT: number): Vec3 {
const k1 = deriv(p[0], p[1], p[2]);
const k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
const k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
const k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
// 통과 하나를 3x3 가우시안 근사 스플랏으로 누적
const W9 = [0.30, 0.55, 0.30, 0.55, 1.0, 0.55, 0.30, 0.55, 0.30];
export default function LorenzPoincareSectionSlice({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
dt = DT,
steps = STEPS
}: LorenzPoincareSectionSliceProps) {
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 cuRef = useRef<RGB | null>(null); // 상승 통과 — 얼음 파스텔
const cdRef = useRef<RGB | null>(null); // 하강 통과 — 복숭아 파스텔
const panelRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array } | null>(null);
const crossRef = useRef<{ crX: number[]; crY: number[]; crUp: number[] } | null>(null);
const histURef = useRef<Float32Array | null>(null);
const histDRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 사전 적분 + 통과 목록 — 과도기 400걸음 버림
function precompute(n: number, step: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let p: Vec3 = [0.6, 0.7, 21];
for (let i = 0; i < 400; i++) p = rk4(p, step);
for (let i = 0; i < n; i++) {
xs[i] = p[0]; ys[i] = p[1]; zs[i] = p[2];
p = rk4(p, step);
}
// 통과 목록 — z 가 27 을 건너는 순간의 보간 (x, y) 와 방향(1 상승 / 0 하강)
const crX: number[] = [], crY: number[] = [], crUp: number[] = [];
for (let i = 1; i < n; i++) {
const za = zs[i - 1] - Z_PLANE, zb = zs[i] - Z_PLANE;
if ((za < 0 && zb >= 0) || (za >= 0 && zb < 0)) {
const t2 = za / (za - zb);
crX.push(xs[i - 1] + (xs[i] - xs[i - 1]) * t2);
crY.push(ys[i - 1] + (ys[i] - ys[i - 1]) * t2);
crUp.push(zb > za ? 1 : 0);
}
}
trajRef.current = { xs, ys, zs };
crossRef.current = { crX, crY, crUp };
}
// 통과 하나를 3x3 가우시안 근사 스플랏으로 누적
function splat(cx2: number, cy2: number, up: number): void {
const histU = histURef.current;
const histD = histDRef.current;
if (!histU || !histD) return;
const gx = (cx2 / 24 + 0.5) * (RES - 1);
const gy = (1 - (cy2 + 28) / 56) * (RES - 1);
const gx0 = Math.round(gx), gy0 = Math.round(gy);
const dst = up ? histU : histD;
for (let j = -1; j <= 1; j++) {
for (let k2 = -1; k2 <= 1; k2++) {
const ix = gx0 + k2, iy = gy0 + j;
if (ix < 0 || ix >= RES || iy < 0 || iy >= RES) continue;
const w = W9[(j + 1) * 3 + (k2 + 1)];
dst[iy * RES + ix] += w;
}
}
}
function splatUpTo(count: number): void {
const cross = crossRef.current;
if (!cross) return;
for (let i = 0; i < count; i++) splat(cross.crX[i], cross.crY[i], cross.crUp[i]);
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const histU = histURef.current;
const histD = histDRef.current;
const PANEL = panelRef.current;
const CU = cuRef.current;
const CD = cdRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !histU || !histD || !PANEL || !CU || !CD) return;
const { xs, ys } = traj;
const STEPSN = xs.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const upto = Math.min(1, phase / REVEAL_END);
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 배경 — xy 투영 전체 궤적이 순차 공개되며 단면의 맥락을 깐다
const s = Math.min(W / 50, H / 58);
const n = Math.floor(upto * STEPSN);
if (n > 2) {
ctx.strokeStyle = rgba(CU, 0.09 * fade);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W / 2 + xs[0] * s, H / 2 - ys[0] * s);
for (let i = 1; i < n; i++) ctx.lineTo(W / 2 + xs[i] * s, H / 2 - ys[i] * s);
ctx.stroke();
}
// 단면 지도 — 두 방향 히스토그램을 감마 램프로 합성
let maxU = 0, maxD = 0;
for (let i = 0; i < histU.length; i++) {
if (histU[i] > maxU) maxU = histU[i];
if (histD[i] > maxD) maxD = histD[i];
}
if (maxU <= 0) maxU = 1;
if (maxD <= 0) maxD = 1;
const sideW = W * 0.44, sideH = H * 0.9;
const cellW = sideW / RES, cellH = sideH / RES;
ctx.save();
ctx.translate((W - sideW) / 2, (H - sideH) / 2);
for (let j = 0; j < RES; j++) {
for (let k2 = 0; k2 < RES; k2++) {
const su = Math.pow(histU[j * RES + k2] / maxU, 0.34);
const sd = Math.pow(histD[j * RES + k2] / maxD, 0.34);
const tot = su + sd;
if (tot <= 0.003) continue;
const col = mix(CU, CD, sd / tot);
const alpha = Math.min(1, 0.12 + tot * 0.85) * fade;
ctx.fillStyle = "rgba(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cellW, j * cellH, cellW + 0.5, cellH + 0.5);
}
}
ctx.restore();
}
let t0 = -1;
let splatted = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const cross = crossRef.current;
if (!cross) return;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
const want = Math.floor(Math.min(1, phase / REVEAL_END) * cross.crX.length);
if (want !== splatted) { // 통과 수가 변할 때만 다시 스플랏
histURef.current = new Float32Array(RES * RES);
histDRef.current = new Float32Array(RES * RES);
splatUpTo(want);
splatted = want;
}
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
cuRef.current = tokRGB(root, "--lz-cu");
cdRef.current = tokRGB(root, "--lz-cd");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
histURef.current = new Float32Array(RES * RES);
histDRef.current = new Float32Array(RES * RES);
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduced()) {
splatUpTo(crossRef.current ? crossRef.current.crX.length : 0); // reduce — 다 찬 단면 지도 한 장
draw(0.8);
setStill(true);
} else {
t0 = -1;
splatted = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, dt]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}07 · 푸앵카레 단면 — 궤적이 평면을 지나는 순간만 찍는다 — 측정 누적면적 3.425% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
3차원 흐름을 2차원 지도로 접는 고전 기법의 화면 이식으로, RK4 dt 0.005 로 촘촘히 14,000걸음(총 70 시간단위)을 적분하며 이전 z 와 현재 z 가 기준면 z=27 을 건넌 순간을 선형 보간해 (x, y) 에만 점을 찍습니다. 상승 통과는 얼음색 · 하강 통과는 복숭아색으로 나눠 각 날개에 띠가 자라고 통과마다 3×3 가우시안 근사 스플랫을 150px 지도에 누적하는데, 통과 순간만 찍는 점 구름이라 누적면적 3.425% · 강도평균 25.9 입니다.
z 27 건넘의 선형 보간
// 통과 목록 — z 가 27 을 건너는 순간의 보간 (x, y) 와 방향(1 상승 / 0 하강)
var crX = [], crY = [], crUp = [];
for (i = 1; i < STEPS; i++) {
var za = zs[i - 1] - Z_PLANE, zb = zs[i] - Z_PLANE;
if ((za < 0 && zb >= 0) || (za >= 0 && zb < 0)) {
var t2 = za / (za - zb);
crX.push(xs[i - 1] + (xs[i] - xs[i - 1]) * t2);
crY.push(ys[i - 1] + (ys[i] - ys[i - 1]) * t2);
crUp.push(zb > za ? 1 : 0);
}
}
08파라미터 손잡이 — 포인터로 시그마·로를 비틀면 형태가 변한다

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>세 계수를 포인터 위치에 묶으면 나비가 찌그러지고 뒤집히며 새로운 궤도로 태어납니다</span>
</p>
<div class="lz__field" role="application" aria-label="파라미터 손잡이 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">포인터 x 로 σ · y 로 ρ · 12분할 궤적</span>
<span class="lz__meta lz__meta--dim">canvas · 계수 반영 · 60ms 지연</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파라미터 손잡이(08) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(포인터 위치를 σ·ρ 로 읽어 12개 궤적을 다시 적분)가 만든다.
// 무대는 편 공통 심해 남색. 궤적 12색은 얼음→라일락→복숭아 파스텔 그라데이션.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-c0: #cfe3ff; // 궤적 시작 색 — 얼음 파스텔
$lz-c1: #e6d9ff; // 궤적 중간 색 — 라일락 파스텔
$lz-c2: #ffd9c9; // 궤적 끝 색 — 복숭아 파스텔
$lz-head: $subject-cream; // 머리 점 — 크림(토큰)
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-c2: #{$lz-c2};
--lz-head: #{$lz-head};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
cursor: crosshair;
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #e6d9ff;
--lz-c2: #ffd9c9;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
cursor: crosshair;
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 파라미터 손잡이(08) — 계수를 손으로 만지는 실험판.
// 포인터 가로 위치를 σ(4~24), 세로 위치를 ρ(10~45)로 읽고, 현재 계수는 τ 0.06(=60ms)마다
// 목표를 향해 다가간다. 계수가 바뀌면 12개 시드의 궤적을 매 프레임 다시 적분해 보여준다.
// (σ, ρ) 평면을 훑으면 카오스 영역과 고정점으로 빠지는 영역의 경계가 손에 잡힌다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var ORBITS = 12; // 동시 궤적 수 — 시드를 원주 12분할에 뿌린다
var STEPS = 900; // 궤적당 걸음 수
var DT = 0.012; // 오일러 걸음
var TAU = 0.06; // 계수 추종 시정수(초) — 설정 60ms 지연과 같다
// ────────────────────────────────────────────────────
var BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 C0 = tokRGB('--lz-c0'); // 궤적 시작 — 얼음
var C1 = tokRGB('--lz-c1'); // 궤적 중간 — 라일락
var C2 = tokRGB('--lz-c2'); // 궤적 끝 — 복숭아
var HEAD = tokRGB('--lz-head'); // 머리 — 크림(토큰)
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function orbitColor(k) {
var t = k / (ORBITS - 1);
return t < 0.5 ? mix(C0, C1, t * 2) : mix(C1, C2, (t - 0.5) * 2);
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 계수 상태 — 현재값이 목표를 τ 60ms 로 쫓는다
var sig = 10, rho = 28;
var sigT = 10, rhoT = 28;
var hasPtr = false;
// 시드 — 어트랙터 중심 두 날개 축을 원주 12분할로 감싼다
var seeds = [];
var k;
for (k = 0; k < ORBITS; k++) {
var ang = k / ORBITS * 6.2832;
seeds.push([8.5 + Math.cos(ang) * 6.5, 8.5 + Math.sin(ang) * 6.5, 26 + Math.cos(ang * 2) * 3]);
}
// 계수에 맞춰 12궤적을 다시 적분 — 계수가 살아 있으므로 매 프레임 재계산한다
var bufX = new Float32Array(STEPS), bufZ = new Float32Array(STEPS);
function traceOrbit(sx, sy, sz, sigv, rhov) {
var x = sx, y = sy, z = sz, i;
for (i = 0; i < 60; i++) { // 과도기
var dx0 = sigv * (y - x), dy0 = x * (rhov - z) - y, dz0 = x * y - BETA * z;
x += dx0 * DT; y += dy0 * DT; z += dz0 * DT;
}
for (i = 0; i < STEPS; i++) {
bufX[i] = x; bufZ[i] = z;
var dx = sigv * (y - x), dy = x * (rhov - z) - y, dz = x * y - BETA * z;
x += dx * DT; y += dy * DT; z += dz * DT;
if (x > 900 || x < -900 || z > 900 || z < -900) { // 발산 가드
for (var i2 = i + 1; i2 < STEPS; i2++) { bufX[i2] = x; bufZ[i2] = z; }
return i;
}
}
return STEPS;
}
var lastSig = -1, lastRho = -1, lastT = 0;
function draw(dt, tSec) {
// τ 추종 — 목표 계수에 60ms 시정수로 다가간다
var f = 1 - Math.exp(-dt / TAU);
sig += (sigT - sig) * f;
rho += (rhoT - rho) * f;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
// σ·ρ 변화가 임계를 넘을 때만 재적분 — 매 프레임 재계산 낭비를 막는다
var need = Math.abs(sig - lastSig) > 0.05 || Math.abs(rho - lastRho) > 0.1;
var s = Math.min(W / 48, H / 54);
var ox = W / 2, oy = H * 0.52;
var k2, i3, n, col, hx, hy;
for (k2 = 0; k2 < ORBITS; k2++) {
if (need || orbits[k2] === null) {
n = traceOrbit(seeds[k2][0], seeds[k2][1], seeds[k2][2], sig, rho);
orbits[k2] = [Array.prototype.slice.call(bufX, 0, n), Array.prototype.slice.call(bufZ, 0, n)];
}
var orbx = orbits[k2][0], orbz = orbits[k2][1];
var L = orbx.length;
if (L < 8) continue;
col = orbitColor(k2);
// 혜성 꼬리 — 궤도 위를 순회하는 밝은 꼬리가 항상 움직임을 만든다
var tip = Math.floor(((tSec * 0.45 + k2 / ORBITS) % 1) * (L - 1));
var from = Math.max(0, tip - 150);
// 뒤네루게 — 궤도 전체를 옅게 깔아 형태를 보여준다
ctx.strokeStyle = rgba(col, 0.10);
ctx.lineWidth = 3.0;
ctx.beginPath();
ctx.moveTo(ox + orbx[0] * s, oy - (orbz[0] - 25) * s);
for (i3 = 1; i3 < L; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
// 알맹이 꼬리 — 진행 방향으로 갈수록 밝아진다
ctx.lineWidth = 1.3;
var seg = 6;
for (var q = 0; q < seg; q++) {
var q0 = from + Math.floor((tip - from) * q / seg);
var q1 = from + Math.floor((tip - from) * (q + 1) / seg);
if (q1 <= q0) continue;
ctx.strokeStyle = rgba(col, 0.14 + 0.62 * (q + 1) / seg);
ctx.beginPath();
ctx.moveTo(ox + orbx[q0] * s, oy - (orbz[q0] - 25) * s);
for (i3 = q0 + 1; i3 <= q1; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
}
// 머리
hx = ox + orbx[tip] * s; hy = oy - (orbz[tip] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.20);
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 0.9);
ctx.beginPath(); ctx.arc(hx, hy, 1.8, 0, 6.2832); ctx.fill();
}
lastSig = sig; lastRho = rho;
// 계수 읽기 — 현재 σ·ρ 표시
ctx.textAlign = 'left';
ctx.fillStyle = rgba(C0, 0.85);
ctx.fillText('σ ' + sig.toFixed(1), 8, H - 8);
ctx.textAlign = 'right';
ctx.fillStyle = rgba(C2, 0.85);
ctx.fillText('ρ ' + rho.toFixed(1), W - 8, H - 8);
if (!hasPtr) {
ctx.textAlign = 'center';
ctx.fillStyle = rgba(HEAD, 0.5);
ctx.fillText('포인터를 올려 σ · ρ 를 움직여 보세요', W / 2, H - 8);
}
}
var orbits = [];
for (k = 0; k < ORBITS; k++) orbits.push(null);
// 포인터 입력 — field 좌표를 계수 목표로
function setTarget(ev) {
var r = fieldEl.getBoundingClientRect();
var px = (ev.clientX - r.left) / r.width;
var py = (ev.clientY - r.top) / r.height;
px = Math.max(0, Math.min(1, px));
py = Math.max(0, Math.min(1, py));
sigT = 4 + px * 20; // σ 4~24
rhoT = 45 - py * 35; // ρ 10~45 (위가 높은 ρ)
hasPtr = true;
}
fieldEl.addEventListener('pointermove', setTarget);
fieldEl.addEventListener('pointerdown', setTarget);
fieldEl.addEventListener('pointerleave', function () {
sigT = 10; rhoT = 28; // 손을 떼면 고전 계수로 돌아간다
hasPtr = false;
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var dt = Math.min(0.05, (now - lastT) / 1000 || 0.016);
lastT = now;
draw(dt, (now - t0) / 1000);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.016, 0.5);
});
fit();
if (reduce) {
// reduce — 고전 계수 한 장. 재적분 1회 + 안내 문구.
draw(0.016, 0.5);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzPointerParameterKnob — vanilla 판(run/324/demo/08-lorenz-pointer-parameter-knob/index.html) 의 React 포트.
// 포인터 가로 위치를 σ(4~24), 세로 위치를 ρ(10~45)로 읽고, 현재 계수는 τ 0.06(=60ms)마다
// 목표를 향해 다가간다. 계수가 바뀌면 12개 시드의 궤적을 매 프레임 다시 적분해 보여준다.
// (σ, ρ) 평면을 훑으면 카오스 영역과 고정점으로 빠지는 영역의 경계가 손에 잡힌다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzPointerParameterKnob.module.scss";
export type LorenzPointerParameterKnobProps = {
duration?: string;
easing?: string;
color?: string;
orbits?: number; // 동시 궤적 수 — 시드를 원주 12분할에 뿌린다
steps?: number; // 궤적당 걸음 수
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const ORBITS = 12; // 동시 궤적 수
const STEPS = 900; // 궤적당 걸음 수
const DT = 0.012; // 오일러 걸음
const TAU = 0.06; // 계수 추종 시정수(초) — 설정 60ms 지연과 같다
const BETA = 8 / 3;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Orbit = { x: number[]; z: number[] } | null;
const FIELD_LABEL = "파라미터 손잡이 실험판";
const META_LEFT = "포인터 x 로 σ · y 로 ρ · 12분할 궤적";
const META_RIGHT = "canvas · 계수 반영 · 60ms 지연";
const HINT = "세 계수를 포인터 위치에 묶으면 나비가 찌그러지고 뒤집히며 새로운 궤도로 태어납니다";
const GUIDE = "포인터를 올려 σ · ρ 를 움직여 보세요";
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LorenzPointerParameterKnob({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
orbits = ORBITS,
steps = STEPS
}: LorenzPointerParameterKnobProps) {
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 c0Ref = useRef<RGB | null>(null); // 궤적 시작 — 얼음
const c1Ref = useRef<RGB | null>(null); // 궤적 중간 — 라일락
const c2Ref = useRef<RGB | null>(null); // 궤적 끝 — 복숭아
const headRef = useRef<RGB | null>(null); // 머리 — 크림(토큰)
const panelRef = useRef<RGB | null>(null);
const orbitsRef = useRef<Orbit[]>([]);
const bufXRef = useRef<Float32Array | null>(null);
const bufZRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
// 계수 상태 — 현재값이 목표를 τ 60ms 로 쫓는다
const sigRef = useRef(10) as Box<number>;
const rhoRef = useRef(28) as Box<number>;
const sigTRef = useRef(10) as Box<number>;
const rhoTRef = useRef(28) as Box<number>;
const hasPtrRef = useRef(false) as Box<boolean>;
const lastSigRef = useRef(-1) as Box<number>;
const lastRhoRef = useRef(-1) as Box<number>;
const lastTRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 시드 — 어트랙터 중심 두 날개 축을 원주 12분할로 감싼다
function makeSeeds(n: number): [number, number, number][] {
const seeds: [number, number, number][] = [];
for (let k = 0; k < n; k++) {
const ang = k / n * 6.2832;
seeds.push([8.5 + Math.cos(ang) * 6.5, 8.5 + Math.sin(ang) * 6.5, 26 + Math.cos(ang * 2) * 3]);
}
return seeds;
}
// 계수에 맞춰 12궤적을 다시 적분 — 계수가 살아 있으므로 매 프레임 재계산한다
function traceOrbit(sx: number, sy: number, sz: number, sigv: number, rhov: number, stepsN: number): number {
const bufX = bufXRef.current;
const bufZ = bufZRef.current;
if (!bufX || !bufZ) return 0;
let x = sx, y = sy, z = sz;
for (let i = 0; i < 60; i++) { // 과도기
const dx0 = sigv * (y - x), dy0 = x * (rhov - z) - y, dz0 = x * y - BETA * z;
x += dx0 * DT; y += dy0 * DT; z += dz0 * DT;
}
for (let i = 0; i < stepsN; i++) {
bufX[i] = x; bufZ[i] = z;
const dx = sigv * (y - x), dy = x * (rhov - z) - y, dz = x * y - BETA * z;
x += dx * DT; y += dy * DT; z += dz * DT;
if (x > 900 || x < -900 || z > 900 || z < -900) { // 발산 가드
for (let i2 = i + 1; i2 < stepsN; i2++) { bufX[i2] = x; bufZ[i2] = z; }
return i;
}
}
return stepsN;
}
function draw(dt: number, tSec: number): void {
const ctx = ctxRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const C2 = c2Ref.current;
const HEAD = headRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !C0 || !C1 || !C2 || !HEAD || !PANEL) return;
// τ 추종 — 목표 계수에 60ms 시정수로 다가간다
const f = 1 - Math.exp(-dt / TAU);
sigRef.current += (sigTRef.current - sigRef.current) * f;
rhoRef.current += (rhoTRef.current - rhoRef.current) * f;
const sig = sigRef.current, rho = rhoRef.current;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
// σ·ρ 변화가 임계를 넘을 때만 재적분 — 매 프레임 재계산 낭비를 막는다
const need = Math.abs(sig - lastSigRef.current) > 0.05 || Math.abs(rho - lastRhoRef.current) > 0.1;
const s = Math.min(W / 48, H / 54);
const ox = W / 2, oy = H * 0.52;
const seeds = makeSeeds(orbits);
const orbs = orbitsRef.current;
if (!orbs) return;
for (let k2 = 0; k2 < orbits; k2++) {
if (need || orbs[k2] === null) {
const n = traceOrbit(seeds[k2][0], seeds[k2][1], seeds[k2][2], sig, rho, steps);
const bx = bufXRef.current, bz = bufZRef.current;
if (!bx || !bz) return;
orbs[k2] = {
x: Array.prototype.slice.call(bx, 0, n) as number[],
z: Array.prototype.slice.call(bz, 0, n) as number[]
};
}
const orb = orbs[k2];
if (!orb) continue;
const orbx = orb.x, orbz = orb.z;
const L = orbx.length;
if (L < 8) continue;
const t = k2 / (orbits - 1);
const col: RGB = t < 0.5 ? mix(C0, C1, t * 2) : mix(C1, C2, (t - 0.5) * 2);
// 혜성 꼬리 — 궤도 위를 순회하는 밝은 꼬리가 항상 움직임을 만든다
const tip = Math.floor(((tSec * 0.45 + k2 / orbits) % 1) * (L - 1));
const from = Math.max(0, tip - 150);
// 뒤네루게 — 궤도 전체를 옅게 깔아 형태를 보여준다
ctx.strokeStyle = rgba(col, 0.10);
ctx.lineWidth = 3.0;
ctx.beginPath();
ctx.moveTo(ox + orbx[0] * s, oy - (orbz[0] - 25) * s);
for (let i3 = 1; i3 < L; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
// 알맹이 꼬리 — 진행 방향으로 갈수록 밝아진다
ctx.lineWidth = 1.3;
const seg = 6;
for (let q = 0; q < seg; q++) {
const q0 = from + Math.floor((tip - from) * q / seg);
const q1 = from + Math.floor((tip - from) * (q + 1) / seg);
if (q1 <= q0) continue;
ctx.strokeStyle = rgba(col, 0.14 + 0.62 * (q + 1) / seg);
ctx.beginPath();
ctx.moveTo(ox + orbx[q0] * s, oy - (orbz[q0] - 25) * s);
for (let i3 = q0 + 1; i3 <= q1; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
}
// 머리
const hx = ox + orbx[tip] * s, hy = oy - (orbz[tip] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.20);
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 0.9);
ctx.beginPath(); ctx.arc(hx, hy, 1.8, 0, 6.2832); ctx.fill();
}
lastSigRef.current = sig;
lastRhoRef.current = rho;
// 계수 읽기 — 현재 σ·ρ 표시
ctx.textAlign = "left";
ctx.fillStyle = rgba(C0, 0.85);
ctx.fillText("σ " + sig.toFixed(1), 8, H - 8);
ctx.textAlign = "right";
ctx.fillStyle = rgba(C2, 0.85);
ctx.fillText("ρ " + rho.toFixed(1), W - 8, H - 8);
if (!hasPtrRef.current) {
ctx.textAlign = "center";
ctx.fillStyle = rgba(HEAD, 0.5);
ctx.fillText(GUIDE, W / 2, H - 8);
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const dt = Math.min(0.05, (now - lastTRef.current) / 1000 || 0.016);
lastTRef.current = now;
draw(dt, (now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
const field = fieldRef.current;
if (!root || !cv || !field) return;
c0Ref.current = tokRGB(root, "--lz-c0");
c1Ref.current = tokRGB(root, "--lz-c1");
c2Ref.current = tokRGB(root, "--lz-c2");
headRef.current = tokRGB(root, "--lz-head");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
bufXRef.current = new Float32Array(steps);
bufZRef.current = new Float32Array(steps);
orbitsRef.current = new Array(orbits).fill(null);
fit();
// 포인터 입력 — field 좌표를 계수 목표로
function setTarget(ev: PointerEvent): void {
const fld = fieldRef.current;
if (!fld) return;
const r = fld.getBoundingClientRect();
let px = (ev.clientX - r.left) / r.width;
let py = (ev.clientY - r.top) / r.height;
px = Math.max(0, Math.min(1, px));
py = Math.max(0, Math.min(1, py));
sigTRef.current = 4 + px * 20; // σ 4~24
rhoTRef.current = 45 - py * 35; // ρ 10~45 (위가 높은 ρ)
hasPtrRef.current = true;
}
function onLeave(): void {
sigTRef.current = 10; rhoTRef.current = 28; // 손을 떼면 고전 계수로 돌아간다
hasPtrRef.current = false;
}
field.addEventListener("pointermove", setTarget);
field.addEventListener("pointerdown", setTarget);
field.addEventListener("pointerleave", onLeave);
function onResize(): void {
fit();
if (reduced()) draw(0.016, 0.5);
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 고전 계수 한 장. 재적분 1회 + 안내 문구.
draw(0.016, 0.5);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
field.removeEventListener("pointermove", setTarget);
field.removeEventListener("pointerdown", setTarget);
field.removeEventListener("pointerleave", onLeave);
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orbits, steps]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}08 · 파라미터 손잡이 — 포인터로 시그마·로를 비틀면 형태가 변한다 — 측정 누적면적 9.233% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
포인터 x 로 σ(4~24) · y 로 ρ(10~45) 를 정하고 두 날개 축을 원주 12분할로 감싼 시드 12개의 궤적을 매 프레임 다시 적분해 보여주는 계수 탐험 판입니다. 계수는 즉시 반영하지 않고 τ 0.06초(60ms) 추종 지연으로 다가가서, 손을 움직이면 나비가 찌그러지고 뒤집히며 새 궤도로 태어나는데 측정은 누적면적 9.233% · 강도평균 37.6 입니다.
계수를 물고 매 프레임 다시 그리는 궤적
function traceOrbit(sx, sy, sz, sigv, rhov) {
var x = sx, y = sy, z = sz, i;
for (i = 0; i < 60; i++) { // 과도기
var dx0 = sigv * (y - x), dy0 = x * (rhov - z) - y, dz0 = x * y - BETA * z;
x += dx0 * DT; y += dy0 * DT; z += dz0 * DT;
}
for (i = 0; i < STEPS; i++) {
bufX[i] = x; bufZ[i] = z;
var dx = sigv * (y - x), dy = x * (rhov - z) - y, dz = x * y - BETA * z;
x += dx * DT; y += dy * DT; z += dz * DT;
if (x > 900 || x < -900 || z > 900 || z < -900) { // 발산 가드
for (var i2 = i + 1; i2 < STEPS; i2++) { bufX[i2] = x; bufZ[i2] = z; }
return i;
}
}
return STEPS;
}
09나비 히어로 씬 — 글자·입자와 겹쳐 쓰는 대문 장면

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>밀도 구름을 배경으로 깔고 비네팅·글자·느린 회전을 얹으면 사이언스 서비스 대문 장면이 완성됩니다</span>
</p>
<div class="lz__field" role="application" aria-label="나비 히어로 씬 실험판" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">은하 팔 궤적 12초 · 관찰각 30초</span>
<span class="lz__meta lz__meta--dim">canvas · 은하 궤적 · 항상 움직임</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나비 히어로 씬(09) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(수천 점 밀도 구름 누적 + 30초 주기 느린 회전 + 12초 주기 궤적 헤드)가 만든다.
// 무대는 편 공통 심해 남색. 구름=얼음→복숭아, 글자=크림(토큰).
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-c0: #cfe3ff; // 밀도 낮은 쪽 — 얼음 파스텔
$lz-c1: #ffd9c9; // 밀도 높은 쪽 — 복숭아 파스텔
$lz-head: $subject-cream; // 글자·헤드 — 크림(토큰)
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-head: #{$lz-head};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__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: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__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: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__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: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 나비 히어로 씬(09) — 밀도 구름 위에 타이포를 얹는 대문 장면.
// 2,400점을 동시 적분해 xz 히스토그램 밀도 구름(감마 램프)을 쌓고,
// 관찰각을 30초 주기(SPIN_L)로 아주 느리게 돌린다. 밝은 궤적 한 줄이 구름 위를 12초 주기로
// 흐르며 은하 팔처럼 보이게 하고, 비네팅과 세 줄 글자로 서비스 대문을 완성한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NPTS = 2400; // 동시 점 수 — 밀도 구름 결
var DT = 0.016; // 오일러 걸음 — 프레임당 2회
var SPIN_L = 30.0; // 관찰각 회전 주기(초) — 설정 30초와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
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 C0 = tokRGB('--lz-c0'); // 밀도 낮은 쪽 — 얼음
var C1 = tokRGB('--lz-c1'); // 밀도 높은 쪽 — 복숭아
var HEAD = tokRGB('--lz-head'); // 글자·헤드 — 크림(토큰)
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var RES = 150; // 밀도 히스토그램 해상도
var hist = new Float32Array(RES * RES);
var px = new Float32Array(NPTS), py = new Float32Array(NPTS), pz = new Float32Array(NPTS);
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
}
function rng(seed) {
var t = seed;
return function () {
t += 0x6D2B79F5;
var r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
var rand = rng(99);
function seedPoints() {
var i;
for (i = 0; i < NPTS; i++) {
px[i] = 0.6 + (rand() - 0.5) * 0.02;
py[i] = 0.7 + (rand() - 0.5) * 0.02;
pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
function stepAll() {
var i, k, dx, dy, dz;
for (k = 0; k < 2; k++) {
for (i = 0; i < NPTS; i++) {
var x = px[i], y = py[i], z = pz[i];
dx = SIG * (y - x); dy = x * (RHO - z) - y; dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
// 회전 각도에 맞춰 xz 히스토그램에 누적 — 회전은 x/y 축 섞기로 표현
function accumulate(a) {
var ca = Math.cos(a), sa = Math.sin(a);
var i, u, v, gx, gy;
for (i = 0; i < NPTS; i++) {
u = px[i] * ca - py[i] * sa;
v = px[i] * sa + py[i] * ca;
gx = (u / 22 + 0.5) * (RES - 1);
gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
function blit(tSec) {
var maxA = 0, i;
for (i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.96;
var ox = (W - side) / 2, oy = (H - side) / 2;
var cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
var j, k2, v, g, col, alpha;
for (j = 0; j < RES; j++) {
for (k2 = 0; k2 < RES; k2++) {
v = hist[j * RES + k2];
if (v <= 0) continue;
g = Math.pow(v / maxA, 0.38);
col = mix(C0, C1, g);
alpha = Math.min(1, 0.08 + g * 0.80);
ctx.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + alpha + ')';
ctx.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
// 은하 팔 — 밝은 단일 궤적이 구름 위를 크리프한다
// (사전 적분 궤적에서 phase 로 읽는 게 아니라, tSec 에 τ 크리프를 태워 머리를 끌고 다닌다)
var armT = (tSec % 12) / 12;
var armN = 700;
var ai = Math.floor(armT * armN);
var s2 = Math.min(W, H) / 52;
var ca2 = Math.cos(tSec / SPIN_L * 6.2832), sa2 = Math.sin(tSec / SPIN_L * 6.2832);
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(HEAD, 0.5);
ctx.lineWidth = 2.2;
ctx.beginPath();
var a0 = Math.max(0, ai - 160), hx2, hy2, p2, u2, v2;
for (i = a0; i <= ai; i++) {
p2 = armXZ(i);
u2 = p2[0] * ca2 - p2[1] * sa2;
hx2 = ox + (u2 / 22 + 0.5) * side;
hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
if (i === a0) ctx.moveTo(hx2, hy2); else ctx.lineTo(hx2, hy2);
}
ctx.stroke();
// 팔 머리 — 크림 점
if (ai >= 0) {
p2 = armXZ(ai);
u2 = p2[0] * ca2 - p2[1] * sa2;
hx2 = ox + (u2 / 22 + 0.5) * side;
hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx2, hy2, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx2, hy2, 2.2, 0, 6.2832); ctx.fill();
}
// 비네팅 — 가장자리를 어둡게 눌러 글자가 뜨게 한다
var vg = ctx.createRadialGradient(W / 2, H * 0.46, Math.min(W, H) * 0.28, W / 2, H / 2, Math.max(W, H) * 0.75);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(4,9,16,0.62)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// 타이포 — 대문 세 줄
ctx.textAlign = 'center';
ctx.fillStyle = rgba(HEAD, 0.94);
ctx.font = '700 15px -apple-system, "Segoe UI", sans-serif';
ctx.fillText('CHAOS BUTTERFLY', W / 2, H * 0.40);
ctx.fillStyle = rgba(HEAD, 0.62);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText('로렌츠 어트랙터 시뮬레이터', W / 2, H * 0.40 + 18);
ctx.fillStyle = rgba(HEAD, 0.40);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText('dz/dt = xy − βz', W / 2, H * 0.40 + 33);
}
// 은하 팔용 사전 적분 궤적 (x, y, z)
var armPts = [];
(function () {
var x = 0.6, y = 0.7, z = 21, i;
var adt = 0.012;
for (i = 0; i < 300; i++) {
var dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
x += dx * adt; y += dy * adt; z += dz * adt;
}
for (i = 0; i < 700; i++) {
armPts.push([x, y, z]);
var dx2 = SIG * (y - x), dy2 = x * (RHO - z) - y, dz2 = x * y - BETA * z;
x += dx2 * adt; y += dy2 * adt; z += dz2 * adt;
}
})();
function armXZ(i) { return armPts[Math.max(0, Math.min(armPts.length - 1, i))]; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, lastT = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var tSec = (now - t0) / 1000;
stepAll();
accumulate(tSec / SPIN_L * 6.2832);
blit(tSec);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) blit(3);
});
fit();
seedPoints();
if (reduce) {
var i;
for (i = 0; i < 120; i++) { stepAll(); accumulate(0.3); } // reduce — 쌓인 한 장
blit(3);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzButterflyWingHero — vanilla 판(run/324/demo/09-lorenz-butterfly-wing-hero/index.html) 의 React 포트.
// 2,400점을 동시 적분해 xz 히스토그램 밀도 구름(감마 램프)을 쌓고,
// 관찰각을 30초 주기(SPIN_L)로 아주 느리게 돌린다. 밝은 궤적 한 줄이 구름 위를 12초 주기로
// 흐르며 은하 팔처럼 보이게 하고, 비네팅과 세 줄 글자로 서비스 대문을 완성한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzButterflyWingHero.module.scss";
export type LorenzButterflyWingHeroProps = {
duration?: string;
easing?: string;
color?: string;
npts?: number; // 동시 점 수 — 밀도 구름 결
spinL?: number; // 관찰각 회전 주기(초) — 설정 30초와 같다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NPTS = 2400; // 동시 점 수
const DT = 0.016; // 오일러 걸음 — 프레임당 2회
const SPIN_L = 30.0; // 관찰각 회전 주기(초) — 설정 30초와 같다
const RES = 150; // 밀도 히스토그램 해상도
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나비 히어로 씬 실험판";
const META_LEFT = "은하 팔 궤적 12초 · 관찰각 30초";
const META_RIGHT = "canvas · 은하 궤적 · 항상 움직임";
const HINT = "밀도 구름을 배경으로 깔고 비네팅·글자·느린 회전을 얹으면 사이언스 서비스 대문 장면이 완성됩니다";
const TITLE = "CHAOS BUTTERFLY";
const SUB1 = "로렌츠 어트랙터 시뮬레이터";
const SUB2 = "dz/dt = xy − βz";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function rng(seed: number): () => number {
let t = seed;
return function (): number {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
// 은하 팔용 사전 적분 궤적 (x, y, z)
function buildArmPts(): number[][] {
const armPts: number[][] = [];
let x = 0.6, y = 0.7, z = 21;
const adt = 0.012;
for (let i = 0; i < 300; i++) {
const dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
x += dx * adt; y += dy * adt; z += dz * adt;
}
for (let i = 0; i < 700; i++) {
armPts.push([x, y, z]);
const dx2 = SIG * (y - x), dy2 = x * (RHO - z) - y, dz2 = x * y - BETA * z;
x += dx2 * adt; y += dy2 * adt; z += dz2 * adt;
}
return armPts;
}
export default function LorenzButterflyWingHero({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
npts = NPTS,
spinL = SPIN_L
}: LorenzButterflyWingHeroProps) {
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 c0Ref = useRef<RGB | null>(null); // 밀도 낮은 쪽 — 얼음
const c1Ref = useRef<RGB | null>(null); // 밀도 높은 쪽 — 복숭아
const headRef = useRef<RGB | null>(null); // 글자·헤드 — 크림(토큰)
const panelRef = useRef<RGB | null>(null);
const histRef = useRef<Float32Array | null>(null);
const ptsRef = useRef<{ px: Float32Array; py: Float32Array; pz: Float32Array } | null>(null);
const armRef = useRef<number[][] | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
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 fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function allocPoints(n: number): void {
ptsRef.current = {
px: new Float32Array(n),
py: new Float32Array(n),
pz: new Float32Array(n)
};
}
function seedPoints(): void {
const pts = ptsRef.current;
if (!pts) return;
const rand = rng(99);
for (let i = 0; i < pts.px.length; i++) {
pts.px[i] = 0.6 + (rand() - 0.5) * 0.02;
pts.py[i] = 0.7 + (rand() - 0.5) * 0.02;
pts.pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
function stepAll(): void {
const pts = ptsRef.current;
if (!pts) return;
const { px, py, pz } = pts;
const N = px.length;
for (let k = 0; k < 2; k++) {
for (let i = 0; i < N; i++) {
const x = px[i], y = py[i], z = pz[i];
const dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
// 회전 각도에 맞춰 xz 히스토그램에 누적 — 회전은 x/y 축 섞기로 표현
function accumulate(a: number): void {
const pts = ptsRef.current;
const hist = histRef.current;
if (!pts || !hist) return;
const { px, py, pz } = pts;
const ca = Math.cos(a), sa = Math.sin(a);
for (let i = 0; i < px.length; i++) {
const u = px[i] * ca - py[i] * sa;
const gx = (u / 22 + 0.5) * (RES - 1);
const gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
function armXZ(i: number): number[] {
const arm = armRef.current;
if (!arm) return [0, 0, 0];
return arm[Math.max(0, Math.min(arm.length - 1, i))];
}
function blit(tSec: number): void {
const ctx = ctxRef.current;
const hist = histRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !hist || !C0 || !C1 || !HEAD || !PANEL) return;
let maxA = 0;
for (let i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.96;
const ox = (W - side) / 2, oy = (H - side) / 2;
const cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
for (let j = 0; j < RES; j++) {
for (let k2 = 0; k2 < RES; k2++) {
const v = hist[j * RES + k2];
if (v <= 0) continue;
const g = Math.pow(v / maxA, 0.38);
const col = mix(C0, C1, g);
const alpha = Math.min(1, 0.08 + g * 0.80);
ctx.fillStyle = "rgba(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
// 은하 팔 — 밝은 단일 궤적이 구름 위를 크리프한다
// (사전 적분 궤적에서 phase 로 읽는 게 아니라, tSec 에 τ 크리프를 태워 머리를 끌고 다닌다)
const armT = (tSec % 12) / 12;
const armN = 700;
const ai = Math.floor(armT * armN);
const ca2 = Math.cos(tSec / SPIN_L * 6.2832), sa2 = Math.sin(tSec / SPIN_L * 6.2832);
ctx.lineCap = "round";
ctx.strokeStyle = rgba(HEAD, 0.5);
ctx.lineWidth = 2.2;
ctx.beginPath();
const a0 = Math.max(0, ai - 160);
for (let i = a0; i <= ai; i++) {
const p2 = armXZ(i);
const u2 = p2[0] * ca2 - p2[1] * sa2;
const hx2 = ox + (u2 / 22 + 0.5) * side;
const hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
if (i === a0) ctx.moveTo(hx2, hy2); else ctx.lineTo(hx2, hy2);
}
ctx.stroke();
// 팔 머리 — 크림 점
if (ai >= 0) {
const p2 = armXZ(ai);
const u2 = p2[0] * ca2 - p2[1] * sa2;
const hx2 = ox + (u2 / 22 + 0.5) * side;
const hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx2, hy2, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx2, hy2, 2.2, 0, 6.2832); ctx.fill();
}
// 비네팅 — 가장자리를 어둡게 눌러 글자가 뜨게 한다
const vg = ctx.createRadialGradient(W / 2, H * 0.46, Math.min(W, H) * 0.28, W / 2, H / 2, Math.max(W, H) * 0.75);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(4,9,16,0.62)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// 타이포 — 대문 세 줄
ctx.textAlign = "center";
ctx.fillStyle = rgba(HEAD, 0.94);
ctx.font = '700 15px -apple-system, "Segoe UI", sans-serif';
ctx.fillText(TITLE, W / 2, H * 0.40);
ctx.fillStyle = rgba(HEAD, 0.62);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText(SUB1, W / 2, H * 0.40 + 18);
ctx.fillStyle = rgba(HEAD, 0.40);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText(SUB2, W / 2, H * 0.40 + 33);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const tSec = (now - t0) / 1000;
stepAll();
accumulate(tSec / SPIN_L * 6.2832);
blit(tSec);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0");
c1Ref.current = tokRGB(root, "--lz-c1");
headRef.current = tokRGB(root, "--lz-head");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
histRef.current = new Float32Array(RES * RES);
allocPoints(npts);
armRef.current = buildArmPts();
fit();
seedPoints();
function onResize(): void {
fit();
if (reduced()) blit(3);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let i = 0; i < 120; i++) { stepAll(); accumulate(0.3); } // reduce — 쌓인 한 장
blit(3);
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
}, [npts, spinL]);
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>{HINT}</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}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}09 · 나비 히어로 씬 — 글자·입자와 겹쳐 쓰는 대문 장면 — 측정 누적면적 10.849% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06 의 밀도 구름(2,400점 · 오일러 dt 0.016)을 배경에 깔고 관찰각을 30초에 한 바퀴 돌리며, 미리 적분한 은하 팔 궤적의 밝은 머리가 구름 위를 12초에 걸쳐 흐릅니다. 비네팅으로 가장자리를 누르고 세 줄 글자(CHAOS BUTTERFLY · 로렌츠 어트랙터 시뮬레이터 · dz/dt = xy − βz)를 얹어 대문 장면을 완성하는데, 측정은 누적면적 10.849% · 강도평균 67.5 입니다.
관찰각 회전을 히스토그램에 누적
// 회전 각도에 맞춰 xz 히스토그램에 누적 — 회전은 x/y 축 섞기로 표현
function accumulate(a) {
var ca = Math.cos(a), sa = Math.sin(a);
var i, u, v, gx, gy;
for (i = 0; i < NPTS; i++) {
u = px[i] * ca - py[i] * sa;
v = px[i] * sa + py[i] * ca;
gx = (u / 22 + 0.5) * (RES - 1);
gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
오일러 dt 를 0.05 이상 올리면 나비가 깨지는 함정
아홉 판을 실제로 만들며 반복해서 걸린 지점은 네 갈래로 좁혀졌습니다 — 오일러 걸음 폭을 너무 크게 잡는 경우, 정사각 스케일을 그대로 쓰는 경우, 단면 통과를 프레임 단위로 검사하는 경우, 그리고 시드 없는 난수를 쓰는 경우입니다. 네 갈래 모두 코드는 에러 없이 돌아가고 화면에도 무언가가 그려지는데, 결과물만 의도한 그림과 달라진다는 공통점이 있어 처음 붙여 볼 때 가장 자주 걸리며, 아래 넷을 순서대로 짚어 두면 대부분 피할 수 있습니다.
오일러 dt 를 크게 잡으면 나비가 무너진다
오일러의 한 걸음 오차는 걸음마다 쌓이므로 dt 를 0.05 이상으로 올리면 궤적이 바깥으로 뻗어나가 두 날개의 균형이 깨집니다. 이 편의 오일러 판들은 01 · 02 · 06 · 09 처럼 혼자 쓰일 때 dt 0.01~0.016 구간에 두었고, dt 를 키우는 실험은 03 처럼 오일러와 RK4 를 나란히 두고 견주는 화면에서 하는 것이 안전합니다.
정사각 스케일은 가로를 절반 비게 한다
02 의 스케일을 min(W, H) 한 변으로만 잡으면 캔버스는 2.4:1 로 넓은데 어트랙터는 1:1 에 가까워 가로가 절반 이상 빕니다. 가로 W/57 · 세로 H/37.5 의 이방 스케일이 누적면적 28.715% 를 만든 열쇠인데, 같은 계산을 하고도 스케일 한 줄로 그림의 밀도가 배로 달라지는 셈입니다.
단면 통과를 프레임 단위로 검사하면 점이 어긋난다
07 은 dt 0.005 로 촘촘히 적분하지만 프레임이 아니라 걸음 사이의 z 변화를 보고, 건넘 순간을 이전 점과 현재 점의 선형 보간으로 좁혀 (x, y) 를 얻습니다. 보간 없이 건넌 걸음의 좌표를 그대로 찍으면 점이 실제 통과 자리에서 멀어져 띠가 번지므로, 보간이 이 지도의 정확도를 결정합니다.
Math.random 은 재생 결정론을 깨뜨린다
04 · 06 · 09 가 시드를 고정한 mulberry32 류 난수를 쓰는 것은 같은 시각의 프레임이 언제나 같은 그림이어야 하기 때문인데, Math.random 을 쓰면 렌더마다 다른 문양이 나와 재생이 결정론을 잃습니다. 아홉 판의 index.html 을 그대로 담은 암호 zip 의 비밀번호는 32hdtxzf 이니, 풀면 본문의 코드 조각과 줄 단위로 나란히 대조해 보실 수 있습니다.
세 변형 중 하나가 그대로 접근성 대응으로 이어지는 이유
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 시간 스텝 | 01 오일러 dt 0.01 → 0.005 | 걸음을 반으로 줄여 궤적이 부드러워지고 같은 시간을 채우는 걸음 수가 두 배가 된다 |
| 계수 로 | 08 시그마 10 · 로 28 → 로 35 | 나비 날개가 더 벌어지며 궤적이 바깥으로 달아나는 주기가 눈에 띄게 짧아진다 |
| 점 개수 | 06 밀도 구름 동시 점 2,000 → 8,000 | 안개가 짙어지며 날개 내부 구조가 하늘의 은하처럼 밝아진다 |
세 변형 모두 판의 구조는 그대로 두고 상수 한 곳만 고쳐 얻습니다. 걸음을 줄이면 정밀과 비용의 관계를 손으로 확인할 수 있고, ρ 를 올리면 흐름의 종류가 바뀌는 것이 한눈에 들어오며, 점을 늘리면 통계 장이 짙어져 같은 식의 다른 얼굴이 나옵니다.
동작을 줄인 사용자에게 주는 장면
각 판은 시작할 때 prefers-reduced-motion: reduce 일치 여부를 검사해, 켜져 있으면 애니메이션 루프를 돌리지 않고 쌓인 대표 장면 한 번을 그려 고정합니다. 정지 사용자에게도 빈 캔버스가 아니라 나비 한 장이 전달되는 것이 목적입니다.
손으로 만지는 판과 화면 낭독
08 만 pointermove 로 σ 와 ρ 를 받고 나머지 판은 자동 재생이며, 모든 실험판에는 사람이 알 수 있는 aria-label 과 tabindex 가 붙고 dt · 걸음 수 · 주기 같은 측정 메타가 화면 아래에 함께 표시됩니다. 04 · 06 · 09 의 난수는 시드 고정이라 같은 시각의 장면이 재현됩니다.
이런 화면에서 쓴다 — 나비 히어로와 파라미터 놀이터
아홉 판 중 실제로 화면에 바로 얹을 수 있는 조합은 셋으로 좁혀집니다. 09 나비 히어로 씬은 글자와 입자를 겹쳐 쓰는 대문 장면이라 제품 소개 페이지의 첫 화면이나 강의 사이트의 인트로에 그대로 옮겨 쓸 수 있고, 배경이 어두운 히어로 영역에서 특히 눈에 띕니다.
08 파라미터 손잡이는 포인터로 시그마·로 값을 직접 비틀어 형태가 바뀌는 판이라, 인터랙티브 데모 페이지에서 방문자가 계수를 만져 보게 하는 용도로 맞습니다. 값이 손잡이 범위를 벗어나지 않도록 08 자체의 발산 가드가 이미 걸려 있어 별도 예외 처리 없이 붙여 쓸 수 있습니다.
06 밀도 구름은 방문 횟수를 밝기로 쌓아 안개처럼 보이는 질감이라, 카드 배경이나 섹션 구분선 뒤에 옅게 깔아 두는 용도로 쓰기 좋습니다. 점 수를 낮추면 계산량이 줄어 배경 용도에 맞는 가벼운 판이 됩니다.
로렌츠 어트랙터를 처음 만질 때 자주 나오는 질문 FAQ
카오스 이론 뜻을 이 나비 하나로 설명할 수 있나요?
가능합니다. 카오스 이론의 뼈대는 결정론 규칙과 초기값 민감성의 공존인데, 이 편의 나비가 정확히 그 두 가지를 보여 줍니다. 세 방정식은 같은 입력에 같은 출력을 주는 결정론이고, 08 에서 계수를 살짝 비틀었을 때 궤적이 전혀 다른 길로 갈라지는 것이 민감성이니, 이 두 장면만으로 뜻을 전달할 수 있습니다.
로렌츠 어트랙터 코드를 수학 배경 없이 따라할 수 있나요?
가능합니다. 코드의 미분방정식은 실수 세 개(x, y, z)를 매 걸음 조금씩 옮기는 덧셈·곱셈으로 풀려 있어 미적분을 몰라도 줄 단위로 따라갈 수 있고, zip 안에는 이 판들을 같은 조건으로 다시 짜라고 시키는 지시 문서가 들어 있어 코드 도구에 붙여 넣으면 같은 화면부터 시작합니다. 그다음은 상수 블록의 σ · ρ · β · dt 값을 하나씩 바꿔 보는 것이 가장 빠른 학습 순서입니다.
계수를 어디까지 바꿔도 화면이 안전한가요?
08 의 손잡이 범위인 σ 4~24 · ρ 10~45 라면 언제든 안전합니다. 이 바깥의 극단값에서는 궤적이 발산해 점이 화면 밖으로 달아날 수 있는데, 08 의 traceOrbit 이 좌표가 ±900 을 넘으면 이후 걸음을 그 자리에 묶는 발산 가드를 두었으니 참고해서 자기 판에 같은 안전장치를 넣으면 됩니다.
다시 보면 — 왜 같은 식인데 매번 다른 길이 나오는가
초기값 민감성이 그림을 지배한다
세 방정식은 완전히 결정론적이라 같은 초기값에서는 언제나 같은 궤적이 나오지만, 초기값에 아주 작은 차이를 심으면 그 차이가 걸음마다 커져 얼마 지나지 않아 전혀 다른 길을 걷습니다. 카오스 이론의 핵심인 이 초기값 민감성은 08 에서 손으로 확인할 수 있는데, 계수를 살짝 비틀 때마다 열두 궤적이 한데 엉키다가 다시 제각각 갈라서는 것이 같은 식 안에서 일어나는 일입니다. 06 의 2,000점도 같은 원리로 흩어지는데, 처음에는 뭉쳐 있던 점 구름이 어트랙터의 두 날개를 따라 늘어나 안개가 되는 것은 각 점의 작은 시작 차이가 시간에 걸쳐 벌어진 결과입니다.
왜 ρ 를 올리면 나비가 달라지나
σ=10 · β=8/3 을 고정하고 ρ 만 움직이면 궤적의 성격이 단계적으로 바뀌는데, ρ 가 낮을 때는 흐름이 한곳에 가라앉고 ρ 28 부근에서 두 날개를 끝없이 오가는 흐름이 나타납니다. 08 은 이 관계를 ρ 10~45 범위의 손잡이로 살려 두었으니, ρ 를 위로 밀수록 날개가 더 벌어지고 바깥으로 달아나는 주기가 눈에 띄게 짧아지는 것을 직접 볼 수 있습니다. 계수는 그림의 장식이 아니라 흐름의 종류를 고르는 스위치입니다.
왜 적분기에 따라 오차가 다른가
오일러는 한 걸음을 출발점 기울기 하나로 계산해 오차가 dt 에 1차 비례해 걸음마다 쌓이지만, RK4 는 네 번의 기울기를 1/6 · 1/3 · 1/3 · 1/6 가중치로 평균 내 오차가 dt 의 네제곱에 비례합니다. 같은 dt 0.02 라도 자릿수 단위로 오차가 벌어지므로 03 의 두 판은 시간이 흐를수록 모양이 갈라지는데, 이것이 정밀 시뮬레이션에 RK4 를 쓰는 이유이자 2,000점 이상을 매 프레임 다시 적분하는 구름 판이 오일러에 작은 dt 를 택해 프레임 예산을 맞추는 이유입니다.









