Lorenz Attractor: 9 Butterfly Chaos Demos
Integrate three simple equations step by step and a butterfly-shaped orbit draws itself. Copy-paste chaos, zero libraries.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Butterfly basics — three differential equations, one Euler step at a time
- 02 3D rotation — spinning the orbit through oblique projection
- 03 RK4 integration — stepping up from Euler to fourth-order Runge-Kutta
- 04 Attractor atlas — Clifford and de Jong, probabilistic flowers from millions of points
- 05 Aizawa and Thomas — a sphere and a spiral, different equations, different sculptures
- 06 Density cloud — visit counts piling into pixel-brightness fog
- 07 Poincare section — stamping only the moments the orbit pierces a plane
- 08 Parameter knobs — pointer twists sigma and rho, the shape answers
- 09 Butterfly hero scene — a front-door stage layered with type and particles
From One Euler Integration to Nine Readings of the Lorenz Butterfly
The nine scenes share one axis: what each does with the trajectory once the integration is done. 01 keeps the line itself, revealed in order so the butterfly draws itself. 02 keeps the line but moves the eye, spinning and tilting it through an oblique projection. 03 turns the question onto the integrator — Euler against fourth-order Runge-Kutta at the same step size. 04 steps outside flows entirely into two 2D recurrences, Clifford and de Jong, where the image is pure visit counting. 05 returns to flows carrying two other systems, Aizawa wrapping a sphere and Thomas spooling thread. 06 swaps the single point for 2,000 and reads the same equations as statistics. 07 folds the 3D flow into a 2D map by keeping only the instants the orbit pierces a plane. 08 hands the two live coefficients to the pointer. 09 stacks cloud, arm, vignette, and type into a front-door scene. Across a 24-frame sample, eight of the nine items transition on 23 of 24 frames, and item 04 on 19 of 24 — its three color channels surface in sequence, so a few sampled frames land between channel steps.
| # | Item | Technique | Painted area | Avg intensity |
|---|---|---|---|---|
| 01 | Butterfly basics | Euler dt 0.01, 3,000 steps, 2s reveal | 4.882% | 51.6 |
| 02 | 3D rotation | oblique projection, scale W/57 and H/37.5 | 28.715% | 102.4 |
| 03 | RK4 integration | Euler vs RK4, both dt 0.02 | 11.656% | 51.9 |
| 04 | Attractor atlas | Clifford and de Jong, 20,000 iterations per channel | 8.677% | 15.5 |
| 05 | Aizawa and Thomas | two other systems, RK4 dt 0.02 | 0.768% | 60.7 |
| 06 | Density cloud | 2,000 points, visit histogram | 1.04% | 72.7 |
| 07 | Poincare section | plane crossings at z = 27, RK4 dt 0.005 | 3.425% | 25.9 |
| 08 | Parameter knobs | pointer sets sigma and rho, 60ms lag | 9.233% | 37.6 |
| 09 | Hero scene | density fog, bright arm, vignette, type | 10.849% | 67.5 |
Code per item
Every fence below is copied from that item's own source file — same constants, same identifier names — with only the comments translated. Each block closes complete, nothing is elided, and the zip carries the nine full files.
01Butterfly basics — three equations, one Euler step at a time
Sigma 10, rho 28, and beta 8/3 integrated with Euler at dt 0.01 for 3,000 pre-computed steps, revealed in order over a 2s cycle that ends at phase 0.82 before fading — 4.882% painted area, a 3.886% peak frame, and 51.6 average intensity. The first 200 steps are integrated and thrown away so the reveal starts on the attractor, and the orbit's 24 chunks of average y drive the depth shading.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Move the position a little each frame from three velocity equations, connect the dots, and the two-lobed butterfly orbit piles up by itself</span>
</p>
<div class="lz__field" role="application" aria-label="Butterfly basics playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">sigma 10 · rho 28 · beta 8/3 · Euler dt 0.01</span>
<span class="lz__meta lz__meta--dim">canvas · 3,000 steps · 2s cycle</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 · Butterfly basics — three differential equations, one Euler step at a time — Measured area 4.882% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The whole system in two functions
// Lorenz velocities — returns dx, dy, dz
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// pre-integration — skip 200 transient steps so the reveal starts on the attractor
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 rotation — the orbit spinning through oblique projection
A 2s rotation about z and a tilt sweeping 0.10 to 1.00 rad on a 14s period feed an oblique projection whose horizontal component spans two axes, so the thread never collapses at any angle — 28.715% painted, the largest of the nine, with an 11.488% peak and the set's brightest average intensity at 102.4. The anisotropic scale, width W/57 and height H/37.5, is what fills both edges of the wide 2.4:1 stage.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Rotate xyz by an angle and project obliquely to screen coordinates — the butterfly turns in space and gains depth</span>
</p>
<div class="lz__field" role="application" aria-label="3D rotation playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">2s rotation · two pairs of sin and cos · no matrices</span>
<span class="lz__meta lz__meta--dim">canvas · depth shading · 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 rotation — spinning the orbit through oblique projection — Measured area 28.715% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Rotating, tilting, then projecting
var a1 = tSec / SPIN_S * 6.2832; // z-axis rotation angle — the fast axis, one turn every 2s
var a2 = Math.sin(tSec / SPIN_L * 6.2832) * 0.45 + 0.55; // view tilt 0.10~1.00rad on a 14s period — never crosses 0
var ca1 = Math.cos(a1), sa1 = Math.sin(a1);
var ca2 = Math.cos(a2), sa2 = Math.sin(a2);
// anisotropic scale — the canvas is a wide 2.4:1 window while the attractor is nearly 1:1,
// so tying both axes to one edge leaves more than half the width empty
var sx = W / 57, sy = H / 37.5;
var ox = W / 2, oy = H / 2;
// oblique projection — after the z rotation (u, w), the tilt a2 bleeds z into the horizontal,
// so the horizontal spans two axes and the thread never collapses at any angle
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 integration — Euler versus fourth-order Runge-Kutta
Both panels integrate the same seed for 2,400 steps at dt 0.02 — the size where Euler's first-order error starts bending the orbit while RK4's four-slope average holds the shape, its error shrinking with the fourth power of the step so the gap between the two panels widens every lap — 11.656% painted, 10.342% peak, and 51.9 intensity. The 2s reveal ends at phase 0.84.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Fourth-order Runge-Kutta averages four slopes per step and stays true far longer than Euler at the same step size</span>
</p>
<div class="lz__field" role="application" aria-label="RK4 integration playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">dt 0.02 · error 64x smaller than Euler</span>
<span class="lz__meta lz__meta--dim">canvas · RK4 four samples · 2s cycle</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 integration — stepping up from Euler to fourth-order Runge-Kutta — Measured area 11.656% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One slope versus four
// one Euler step — a single slope moves the position
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];
}
// one RK4 step — the weighted average of four slopes k1 to 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])
];
}
04Attractor atlas — Clifford and de Jong point clouds
Six coefficient sets — three Clifford, three de Jong — each run 20,000 iterations per channel through a seeded RNG, visits accumulate into a 150px buffer, and the three color channels surface in sequence on a 2s cycle: 8.677% painted, 8.644% peak, and 15.5 intensity. The first 400 steps of each run are the pre-attractor transient, discarded before counting.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Stream tens of thousands of iterations through the Clifford and de Jong recurrences, accumulate visits as brightness, and each coefficient pair unfurls a different pattern</span>
</p>
<div class="lz__field" role="application" aria-label="Attractor atlas playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">2D recurrences · 20k iterations per channel</span>
<span class="lz__meta lz__meta--dim">canvas · density scatter · 2s cycle</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 · Attractor atlas — Clifford and de Jong, probabilistic flowers from millions of points — Measured area 8.677% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password
Feed a recurrence, count the visits
function accumulate(set, upto) { // kind 0 = Clifford, 1 = de Jong
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; // transient discarded
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;
}
}
}
05Aizawa and Thomas — other equations, other sculptures
Aizawa (a sphere-wrapping flow) and Thomas (a thread-spool spiral) integrate with RK4 at dt 0.02 for 1,800 steps each under a slow 12s observation-axis rotation — 0.768% painted, 0.444% peak, and 60.7 intensity. The thin single-strand trajectories are the material, not a defect.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Swap a handful of coefficients and other differential equations draw Aizawa wrapping a sphere and Thomas spinning thread</span>
</p>
<div class="lz__field" role="application" aria-label="Aizawa and Thomas playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">left Aizawa · right Thomas · RK4</span>
<span class="lz__meta lz__meta--dim">canvas · depth shading · 2s cycle</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 · Aizawa and Thomas — a sphere and a spiral, different equations, different sculptures — Measured area 0.768% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Two more velocity fields
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
];
}
// Thomas b = 0.19 — cyclically interlocking spirals
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];
}
06Density cloud — visit counts as pixel brightness
2,000 points seeded near the core with a fixed RNG advance two Euler steps per frame at dt 0.016, and their xz projection accumulates into a 150px histogram — the trajectory read as statistics — with a 2s reset before the pile rebuilds: 1.04% painted, 0.934% peak, and 72.7 intensity.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Release thousands of points at once and add up passes per pixel as brightness — the trajectory becomes a fog-like density field</span>
</p>
<div class="lz__field" role="application" aria-label="Density cloud playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">2,000 points at once · Euler dt 0.016</span>
<span class="lz__meta lz__meta--dim">canvas · histogram · 2s cycle</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 · Density cloud — visit counts piling into pixel-brightness fog — Measured area 1.04% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Step the swarm, then count the passes
// Euler integration — two steps per frame
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;
}
}
}
// visit counts into the histogram buffer — xz projection
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;
}
}
}
07Poincare section — stamping only the plane crossings
RK4 at dt 0.005 for 14,000 steps (70 time units) walks the orbit densely, and whenever consecutive z values straddle the plane at z = 27 the crossing point is linearly interpolated and stamped — upward crossings in ice pastel, downward in peach, each splatted as a 3x3 Gaussian-ish kernel onto the 150px map: 3.425% painted, 2.651% peak, and 25.9 intensity.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Stamp a dot only where the orbit pierces a reference plane and each wing grows a band of dots</span>
</p>
<div class="lz__field" role="application" aria-label="Poincare section playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">section z = 27 · collecting both crossings</span>
<span class="lz__meta lz__meta--dim">canvas · section map · 2s cycle</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 · Poincare section — stamping only the moments the orbit pierces a plane — Measured area 3.425% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The interpolation is the accuracy
// crossings — the interpolated (x, y) and direction at each instant z straddles the plane (1 up, 0 down)
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);
}
}
08Parameter knobs — the pointer twists sigma and rho
Pointer x targets sigma 4 to 24 and y targets rho 10 to 45, twelve seeded orbits re-integrate as the coefficients move, and the values ease toward their targets with a 60ms time constant so the butterfly squashes and rebirths instead of snapping — 9.233% painted, 5.117% peak, and 37.6 intensity. A divergence guard freezes any orbit that escapes past 900.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Bind the three coefficients to pointer position and the butterfly squashes, flips and is reborn as new orbits</span>
</p>
<div class="lz__field" role="application" aria-label="Parameter knob playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">pointer x sets sigma · y sets rho · 12 orbits</span>
<span class="lz__meta lz__meta--dim">canvas · coefficients applied · 60ms lag</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('Hover the pointer to move σ and ρ', 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 · Parameter knobs — pointer twists sigma and rho, the shape answers — Measured area 9.233% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Pointer to coefficient, with a lag
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; // sigma 4~24
rhoT = 45 - py * 35; // rho 10~45 — the top of the field is high rho
hasPtr = true;
}
fieldEl.addEventListener('pointermove', setTarget);
fieldEl.addEventListener('pointerdown', setTarget);
fieldEl.addEventListener('pointerleave', function () {
sigT = 10; rhoT = 28; // pointer gone — back to the classic coefficients
hasPtr = false;
});
09Butterfly hero scene — a front door over the density fog
The 2,400-point cloud at dt 0.016 lays the backdrop, a pre-integrated bright orbit creeps across it over a 12s cycle under a 30s observation rotation, and a vignette plus three type lines — CHAOS BUTTERFLY, Lorenz attractor simulator, dz/dt = xy − βz — finish the scene: 10.849% painted, 3.231% peak, and 67.5 intensity.

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>Lay the density cloud under vignette, type and slow rotation and a science-service front door is done</span>
</p>
<div class="lz__field" role="application" aria-label="Butterfly hero scene playground" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">arm trajectory 12s · view angle 30s</span>
<span class="lz__meta lz__meta--dim">canvas · galaxy trail · always moving</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('Lorenz attractor simulator', 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 − beta 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 · Butterfly hero scene — a front-door stage layered with type and particles — Measured area 10.849% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
The arm and its slow clock
// the bright arm — a pre-integrated orbit of 700 points, read by tSec over a 12s cycle
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); // observation angle, 30s per turn
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();
Why does the same formula trace a different path every time — chaos and sensitivity
The butterfly is the orbit, not an object drawn in advance
Nothing on these canvases is a shape that was modeled and then animated. The two lobes exist only as the set of places the point has visited — every stroke of brightness is a visit, and the wings emerge because the flow keeps folding the trajectory back into the same two regions. Stop on any item and you are looking at a scatter of history, not a drawing.
Deterministic and unpredictable are one fact
The Lorenz attractor equations contain no randomness at all: same start, same coefficients, same steps, and the path reproduces exactly — which is precisely what the seeded RNGs and fixed starting points rely on for deterministic playback. The unpredictability sits one millimeter away. Move the start by a hair and the early laps still track closely, then one lap lands on the other wing and the two histories share nothing afterward. Item 08 shows this live: drag rho a little and within a lap the orbit is somewhere the old orbit never went.
Why the wings hold their shape at all
The orbit circles one lobe a few times, the z coordinate crests, and the crossing of the middle sends it hopping to the other lobe — a rhythm, not a random walk. Item 07 makes the rhythm visible: stamp only the z = 27 crossings and the dots fall into bands, one per wing, because the crossings cluster where the flow's folding is tightest. The butterfly shape is the statistics of that hopping, which is why every rendering style here — line, fog, section map — surfaces the same two wings.
The same family as the other computed fields on this site
Each scene advances state through a field recomputed from nothing but the current state — the same premise as an N-body gravity scene where each star tugs on all the others, the cardioid set that grows new detail at every depth, and a reaction-diffusion plate where two chemicals trade patterns. A strange attractor is the restless member of that family: the other fields let state settle into a pattern, while this one never lets the point stop moving — the pattern is where it wanders, forever.
One of three variants wires straight into reduced-motion accessibility
Three knobs to turn
| Name | Change | Feel |
|---|---|---|
| Time step | item 01 Euler dt 0.01 → 0.005 | the trajectory smooths and the same screen time carries twice the steps |
| Coefficient rho | item 08 sigma 10, rho 28 → rho 35 | the wings spread wider and the outward dashes come visibly sooner |
| Point count | item 06 cloud 2,000 → 8,000 points | the fog thickens and the structure inside the wings brightens like a night-sky galaxy |
Reduced motion still shows an accumulated frame
Each scene looks at prefers-reduced-motion before starting its loop, and when the preference is set the loop is never scheduled. One accumulated scene renders at a fixed phase and freezes with an is-still class on the root, so the frozen frame still shows a complete butterfly instead of an empty rectangle.
One pointer item, eight that run alone
Item 08 listens to pointermove and pointerdown over its field, and pointerleave restores the classic sigma 10, rho 28 — the other eight run on requestAnimationFrame alone. Hosts expose an aria-label and a tabindex, which keeps the grid keyboard-reachable.
An Euler dt above 0.05 is where the butterfly breaks
Nine boards funnel every failure people hit while building this into four shapes: a step size pushed too far, a projection scale left square on a wide stage, a crossing test done at sample granularity instead of interpolated, and randomness left unseeded. All four keep rendering without a single error — the picture just stops matching what the equations actually describe.
A large Euler dt explodes the butterfly
Euler's error is proportional to the step, and it compounds step after step: at dt 0.05 or larger the trajectory shoots outward and the wings collapse within seconds. Item 03 stages that boundary on purpose at dt 0.02, where the Euler panel already distorts after a few laps; the items that ride Euler alone keep dt between 0.01 and 0.016, and item 08's knob sits at 0.012.
One scale factor empties half of a wide canvas
Tying the projection to min(W, H) on a 2.4:1 stage leaves more than half the width blank, because the attractor is nearly 1:1 in extent. Item 02 scales width by W/57 and height by H/37.5 separately — each edge of the wide canvas gets filled, and that anisotropy is how the panel reaches its 28.715% painted area.
Crossing checks at sample granularity land off the plane
Item 07 integrates at dt 0.005, yet snapping to the nearest sample when z crosses 27 still puts dots visibly off the band. The linear interpolation between the previous z and the current z is the map's accuracy — skip it and the section becomes a smear instead of a structure.
RK4 everywhere busts the frame budget
Fourth-order accuracy costs four derivative evaluations per step. Items 06 and 09 re-integrate 2,000 and 2,400 points every frame — two small Euler steps per point fit that budget, while RK4 at the same particle count would quadruple the derivative calls for accuracy the fog cannot display. Precision belongs where the picture is a single trajectory (03, 05, 07); speed belongs where thousands move at once.
Unseeded randomness and kept transients dirty the atlas
Item 04 seeds a mulberry32 stream per coefficient set and item 06 seeds with 42, so the same frame time always rebuilds the same picture — swap in Math.random and every pass redraws differently, determinism gone. The first 400 recurrence steps in 04 are the pre-attractor transient; keep them and a stray line smears the pattern before the counting even starts. The zip of nine source folders behind this post opens with 32hdtxzf and carries the HTML, CSS, and React port of every scene.
Where this actually gets used — a butterfly hero and a parameter playground
Of the nine boards, three fold into real pages without extra work. Item 09's butterfly hero overlays letterforms and particles on the density fog, so it drops straight into a landing page's opening panel or a course site's intro — the effect reads best over a dark hero section.
Item 08's parameter knob lets a visitor drag sigma and rho directly, which fits an interactive demo block or a teaching page where the point is to let someone feel the coefficients move. Its own divergence guard already keeps the trajectory on screen, so no extra bounds-checking is needed to reuse it as-is.
Item 06's density cloud reads as fog rather than a distinct shape, which makes it a quiet card background or a section divider laid behind text. Dropping the point count trims the per-frame cost for that kind of background role.
Questions before you paste this Lorenz attractor code FAQ
Do these Lorenz attractor js scenes need a library or a build step
No. Each item is one self-contained HTML file — a plain canvas 2D context, hand-rolled integrators, no CDN, and no WebGL — so pasting the file into any page runs it as-is. The zip adds a React port of every scene for projects that live in a component tree.
Why do items 05, 06, and 07 paint so little of the canvas
Their 0.768%, 1.04%, and 3.425% are the honest size of the material. Item 05 draws single-strand trajectories, item 06 accumulates thin histogram fog, and item 07 stamps dots only at the instants the orbit pierces the plane — thickening the strokes or smearing the dots would erase exactly what each item exists to show.
Euler or RK4 — which integrator fits a Lorenz attractor animation
For a fixed trajectory shown once, pre-integrate with RK4 and the picture holds its shape — that is items 03, 05, and 07. For per-frame re-integration of thousands of points, small Euler steps win on cost — items 06, 08, and 09. The two panels of item 03 are the whole argument compressed into one screen.
Sources: MDN — CanvasRenderingContext2D · MDN — Window.requestAnimationFrame() · MDN — prefers-reduced-motion · Wikipedia — Lorenz system · Wikipedia — Attractor · Wikipedia — Runge–Kutta methods









