ローレンツアトラクター 9種 — 蝶の形のカオス軌道を1歩ずつ数値積分して積む
3つの微分方程式を1歩ずつ積分すると、蝶の形をした軌道がひとりでに積み上がります。ライブラリなしのコピペで持ち帰れるカオス可視化です。ローレンツアトラクターとは、蝶の絵を持ってきた画像ではなく、速度式3行に点の位置を従わせた計算の結果です。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 蝶の基本 — 3つの微分方程式をオイラー法で1歩ずつ
- 02 3D回転投影像 — 軌道を斜め投影でくるくる回す
- 03 RK4積分 — オイラーから4次ルンゲ・クッタへ精度を上げる
- 04 アトラクター図鑑 — クリフォード・ド・ジョン、数百万点が描く確率の花
- 05 アイザワ・トーマス — 球面と螺旋、別の方程式がくれた別の彫刻
- 06 密度の雲 — 訪問回数をピクセル輝度として積む霧の質感
- 07 ポアンカレ断面 — 軌道が平面を横切る瞬間だけを打つ
- 08 パラメータのつまみ — ポインタでシグマ・ローをひねると形が変わる
- 09 蝶のヒーローシーン — 文字・粒子と重ねる表紙の場面
デモ
九つの盤はそれぞれ index.html と style.css の2ファイルで自立し、開いた瞬間から動きます。24フレームの録画区間で九つのうち八つが23フレーム動き続け、04だけ19フレームです。04は3つの色チャンネルを順番に現す構造なので、チャンネルの切れ目に静止するフレームがあるのが正常です。08でポインタを動かすとσとρが変わって蝶の形がその場で生まれ変わります。
| 項目 | 焦点 | 累積面積(%) | フレーム最大(%) | 強度平均 | フレーム |
|---|---|---|---|---|---|
| 01 蝶の基本 | 3つの速度式をオイラー法で1歩ずつ積む基本画面 | 4.882 | 3.886 | 51.6 | 23/24 |
| 02 3D回転投影像 | 斜め投影で蝶が立体に回る最大面積の盤 | 28.715 | 11.488 | 102.4 | 23/24 |
| 03 RK4積分 | オイラーと4次ルンゲ・クッタの並走比較 | 11.656 | 10.342 | 51.9 | 23/24 |
| 04 アトラクター図鑑 | クリフォード・ド・ジョンの2次元漸化式6組 | 8.677 | 8.644 | 15.5 | 19/24 |
| 05 アイザワ・トーマス | 球面と糸巻き螺旋、別の方程式との比較 | 0.768 | 0.444 | 60.7 | 23/24 |
| 06 密度の雲 | 2,000点の訪問回数を霧のように積む | 1.04 | 0.934 | 72.7 | 23/24 |
| 07 ポアンカレ断面 | z=27 を横切る瞬間だけに点を打つ地図 | 3.425 | 2.651 | 25.9 | 23/24 |
| 08 パラメータのつまみ | ポインタでσ・ρをひねる係数探検 | 9.233 | 5.117 | 37.6 | 23/24 |
| 09 蝶のヒーローシーン | 密度の雲に銀河の腕と文字を重ねた表紙 | 10.849 | 3.231 | 67.5 | 23/24 |
累積面積が最も広いのは02の28.715%で、強度平均102.4も九つで最も明るく、03の11.656%と09の10.849%が続きます。逆に05の0.768%・06の1.04%・07の3.425%が小さいのは欠陥ではなく素材の性質です。05は細い軌道線1本で形を見せ、06は訪問回数を霧のように薄く積むヒストグラム、07は断面を横切る瞬間だけに点を打つ点の雲なので、もともと覆う面積が小さく、線を太くして数値を膨らませると各項目の正体が消えます。01の4.882%も軌道線が1本ずつ積まれる画面として同じ性質です。
オイラー積分一枚からローレンツの蝶を九層で読み直す順序
並び順の基準は派手さではなく、同じ3行の式をどこまで信じられるかです。01はオイラー法の1歩で蝶を積む最小構成、02はその軌道を回して奥行きを読める形に引き伸ばします。03で初めて積分器そのものを疑い、04と05は式を入れ替えて漸化式と別の微分方程式の世界を見せます。06は1点の軌道を2,000点の統計に読み替え、07は3次元の流れを2次元の地図へ折りたたみ、08で初めて手で係数をひねります。09がその計算画面に文字とビネットを重ねてサービスの表紙へ組み立てて締めくくります。重力で星同士が引く銀河のn体シミュレーションや反復で境界を掘るマンデルブロのズームと違うのは、この9種がすべてたった1点の連続時間を積んでいる点です。
項目別コード
ここからは各盤の定数と、動いている関数をそのまま引用します。切り詰めた断片ではなく動いているコードそのものなので、この画面から自分のページへ貼って動かせます。コメントだけこの記事の言葉に置き換えてあり、変数名と関数名はzipの中身と同じ英語のままです。
01蝶の基本 — 3つの微分方程式をオイラー法で1歩ずつ
速度3つで位置を毎フレーム少しずつ動かして線を結ぶと、2つの羽を行き来する蝶がひとりでに積み上がります。オイラー法 dt 0.01 で3,000歩を前もって積分しておき、2秒周期で軌道を順番に公開します。公開が終わる位相0.82から画面がゆっくり消え、ループがつながります。累積面積4.882%は軌道線1本が積まれる画面として妥当な値です。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>3つの速度式で位置を毎フレーム少しずつ動かして線を結ぶと、2つの羽を行き来する蝶の軌道がひとりでに積み上がります</span>
</p>
<div class="lz__field" role="application" aria-label="蝶の基本の実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">σ 10 · ρ 28 · β 8/3 · オイラー dt 0.01</span>
<span class="lz__meta lz__meta--dim">canvas · 3,000歩 · 周期2秒</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나비 기본(01) — @keyframes 를 두지 않는다.
// 움직임은 전부 캔버스 rAF 루프(로렌츠 세 식을 오일러로 적분해 궤적을 순서대로 드러낸다)이 만든다.
// 무대·팔레트는 편 공통 심해 관측 규격 — 밝은 계열 파스텔과 낮은 채도로 유지한다.
$lz-stage: #16202b; // 심해 남색 — 저명도 무대
@include stage($lz-stage);
$lz-panel: #0d1622; // 관측창 바닥 — 무대보다 한 단계 어두운 남검
$lz-text: #ccd8e6; // 라벨 글자 — 밝은 청회
$lz-c0: #cfe3ff; // 얼음 파스텔 — 궤적 꼬리·기본 강조
$lz-c1: #ffd9c9; // 복숭아 파스텔 — 궤적 머리
$lz-c2: #d8f0e3; // 민트 크림 — 두 번째 궤적
$lz-c3: #e6d9ff; // 라일락 화이트 — 세 번째 궤적
$lz-mid: #5f87b8; // 밀도 램프 중간 — 저채도 청색
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --lz-* 를 받아 쓴다
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-c2: #{$lz-c2};
--lz-c3: #{$lz-c3};
--lz-mid: #{$lz-mid};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 드러난 궤적 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-c2: #d8f0e3;
--lz-c3: #e6d9ff;
--lz-mid: #5f87b8;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 나비 기본(01) — 로렌츠 어트랙터의 첫 장면.
// dx = σ(y−x), dy = x(ρ−z)−y, dz = xy−βz 세 속도를 오일러로 한 걸음씩 적분해
// 위치를 옮기고 선을 잇는다. 미리 계산한 3,000걸음을 주기 안에 순서대로 드러내고
// 마지막 18% 구간에서 서서히 지워 루프가 이어진다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.01; // 오일러 걸음 — 0.005 로 줄이면 같은 거리에 두 배 걸음(변형 1)
var STEPS = 3000; // 사전 계산 걸음 수 — 공개 구간에 전부 드러난다
var CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같아 한 바퀴가 한 루프
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C0 = tokRGB('--lz-c0'); // 꼬리 색 — 얼음 파스텔
var C1 = tokRGB('--lz-c1'); // 머리 색 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
var GUIDE = tokRGB('--lz-mid'); // 안내선 — 저채도 청색
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
// 로렌츠 속도 — 반환 순서 dx, dy, dz (단위: 무차원)
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var x = 0.6, y = 0.7, z = 21, i;
for (i = 0; i < 200; i++) {
var d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (i = 0; i < STEPS; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
var d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
// 조각별 평균 깊이 — y 는 관찰 방향이라 멀/가까이가 색 농도와 두께가 된다
var CH = 24;
var chunkNy = new Float32Array(CH);
(function () {
var k, a, b, sum, cnt, ci, v;
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH); b = Math.floor((k + 1) * STEPS / CH);
sum = 0; cnt = 0;
for (ci = a; ci < b; ci++) { sum += ys[ci]; cnt++; }
v = cnt ? (sum / cnt + 27) / 57 : 0.5;
chunkNy[k] = Math.max(0, Math.min(1, v));
}
})();
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
var REVEAL_END = 0.82; // 걸음 공개가 끝나는 위상 — 이후 지우기
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var s = Math.min(W / 44, H / 52);
var ox = W / 2, oy = H / 2;
// 안내선 — 날개 경계 x = 0
ctx.strokeStyle = rgba(GUIDE, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, 0); ctx.lineTo(ox, H);
ctx.stroke();
var upto = Math.min(1, phase / REVEAL_END);
var n = Math.floor(upto * STEPS);
if (n < 2) return;
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 두 번의 stroke — 뒤네루게(넓고 옅게) + 알맹이(가늘고 진하게)
var passes = [[3.2, 0.14], [1.4, 0.85]];
var pass, k, a, b, ci, t, col, ny;
for (pass = 0; pass < 2; pass++) {
var wMul = passes[pass][0], aMul = passes[pass][1];
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH);
b = Math.min(n, Math.floor((k + 1) * STEPS / CH));
if (b - a < 2) continue;
t = (k + 0.5) / CH;
ny = chunkNy[k];
col = mix(C0, C1, t);
ctx.strokeStyle = rgba(col, aMul * (0.45 + 0.55 * ny) * fade);
ctx.lineWidth = wMul * (0.8 + 0.9 * ny);
ctx.beginPath();
ctx.moveTo(ox + xs[a] * s, oy - (zs[a] - 25) * s);
for (ci = a + 1; ci < b; ci++) ctx.lineTo(ox + xs[ci] * s, oy - (zs[ci] - 25) * s);
ctx.stroke();
}
}
// 머리 — 현재 걸음 위치에 크림 점과 옅은 광원
var hx = ox + xs[n - 1] * s, hy = oy - (zs[n - 1] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62);
});
fit();
if (reduce) {
draw(0.62); // reduce — 드러난 궤적 한 장만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzButterflyBasic — vanilla 판(run/324/demo/01-lorenz-butterfly-basic/index.html) 의 React 포트.
// dx = σ(y−x), dy = x(ρ−z)−y, dz = xy−βz 세 속도를 오일러로 한 걸음씩 적분해
// 위치를 옮기고 선을 잇는다. 미리 계산한 3,000걸음을 주기 안에 순서대로 드러내고
// 마지막 18% 구간에서 서서히 지워 루프가 이어진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzButterflyBasic.module.scss";
export type LorenzButterflyBasicProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // 오일러 걸음 — 0.005 로 줄이면 같은 거리에 두 배 걸음(변형 1)
steps?: number; // 사전 계산 걸음 수 — 공개 구간에 전부 드러난다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.01; // 오일러 걸음
const STEPS = 3000; // 사전 계산 걸음 수
const CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같아 한 바퀴가 한 루프
const REVEAL_END = 0.82; // 걸음 공개가 끝나는 위상 — 이후 지우기
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나비 기본 실험판";
const META_LEFT = "σ 10 · ρ 28 · β 8/3 · 오일러 dt 0.01";
const META_RIGHT = "canvas · 3,000걸음 · 주기 2초";
const HINT = "세 개의 속도 식에서 위치를 매 프레임 조금씩 옮겨 선을 잇으면 두 날개를 오가는 나비 궤적이 저절로 쌓입니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
// 로렌츠 속도 — 반환 순서 dx, dy, dz (단위: 무차원)
function deriv(x: number, y: number, z: number): [number, number, number] {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export default function LorenzButterflyBasic({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
dt = DT,
steps = STEPS
}: LorenzButterflyBasicProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const c0Ref = useRef<RGB | null>(null);
const c1Ref = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const guideRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array; chunkNy: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 궤적 사전 계산 — 과도기 200걸음을 건너뛰어 어트랙터 위에서 시작한다
function precompute(n: number, step: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let x = 0.6, y = 0.7, z = 21;
for (let i = 0; i < 200; i++) {
const d0 = deriv(x, y, z);
x += d0[0] * step; y += d0[1] * step; z += d0[2] * step;
}
for (let i = 0; i < n; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
const d1 = deriv(x, y, z);
x += d1[0] * step; y += d1[1] * step; z += d1[2] * step;
}
// 조각별 평균 깊이 — y 는 관찰 방향이라 멀/가까이가 색 농도와 두께가 된다
const CH = 24;
const chunkNy = new Float32Array(CH);
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * n / CH), b = Math.floor((k + 1) * n / CH);
let sum = 0, cnt = 0;
for (let ci = a; ci < b; ci++) { sum += ys[ci]; cnt++; }
const v = cnt ? (sum / cnt + 27) / 57 : 0.5;
chunkNy[k] = Math.max(0, Math.min(1, v));
}
trajRef.current = { xs, ys, zs, chunkNy };
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const PANEL = panelRef.current;
const GUIDE = guideRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !PANEL || !GUIDE || !C0 || !C1 || !HEAD) return;
const { xs, zs, chunkNy } = traj;
const STEPSN = xs.length;
const CH = chunkNy.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const s = Math.min(W / 44, H / 52);
const ox = W / 2, oy = H / 2;
// 안내선 — 날개 경계 x = 0
ctx.strokeStyle = rgba(GUIDE, 0.22);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ox, 0);
ctx.lineTo(ox, H);
ctx.stroke();
const upto = Math.min(1, phase / REVEAL_END);
const n = Math.floor(upto * STEPSN);
if (n < 2) return;
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 두 번의 stroke — 뒤네루게(넓고 옅게) + 알맹이(가늘고 진하게)
const passes: [number, number][] = [[3.2, 0.14], [1.4, 0.85]];
for (let pass = 0; pass < 2; pass++) {
const wMul = passes[pass][0], aMul = passes[pass][1];
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * STEPSN / CH);
const b = Math.min(n, Math.floor((k + 1) * STEPSN / CH));
if (b - a < 2) continue;
const t = (k + 0.5) / CH;
const ny = chunkNy[k];
const col = mix(C0, C1, t);
ctx.strokeStyle = rgba(col, aMul * (0.45 + 0.55 * ny) * fade);
ctx.lineWidth = wMul * (0.8 + 0.9 * ny);
ctx.beginPath();
ctx.moveTo(ox + xs[a] * s, oy - (zs[a] - 25) * s);
for (let ci = a + 1; ci < b; ci++) ctx.lineTo(ox + xs[ci] * s, oy - (zs[ci] - 25) * s);
ctx.stroke();
}
}
// 머리 — 현재 걸음 위치에 크림 점과 옅은 광원
const hx = ox + xs[n - 1] * s, hy = oy - (zs[n - 1] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0"); // 꼬리 색 — 얼음 파스텔
c1Ref.current = tokRGB(root, "--lz-c1"); // 머리 색 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");// 관측창 바닥
guideRef.current = tokRGB(root, "--lz-mid"); // 안내선 — 저채도 청색
ctxRef.current = cv.getContext("2d");
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62); // reduce — 드러난 궤적 한 장만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, dt]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}01 · 蝶の基本 — 3つの微分方程式をオイラー法で1歩ずつ — 計測 累積面積 4.882% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
3行の速度式と過渡のスキップ
deriv がローレンツ系の核心で、返り値はその瞬間の速度 dx・dy・dz です。出発点 (0.6, 0.7, 21) からアトラクターに乗るまでの200歩を捨ててから記録を始めるのが、絵をにじませない唯一の調整です。
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
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で奥行きを色づける
軌道を24区間に割り、区間ごとの平均 y を (y+27)/57 で正規化してアルファと明るさに乗せています。y は観察方向の座標なので、遠い区間は薄く・近い区間は濃く染まり、線1本の絵に奥行きの筋が入ります。
023D回転投影像 — 軌道を斜め投影でくるくる回す
xyz を回転角で画面座標に落とすと、蝶が立体で回って奥行きを感じます。高速の z 軸回転(2秒周期)の後に、14秒周期で0.10〜1.00rad を往復する視線の傾きで z を横にも混ぜる斜め投影です。横成分が2つの軸にまたがるので、どの角度でも軌道が糸1本に崩れません。累積面積28.715%・強度平均102.4は九つで最も広く明るい結果です。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>xyzを回転角で回して画面座標に斜め投影すると、蝶が立体で回って奥行きを感じます</span>
</p>
<div class="lz__field" role="application" aria-label="3D回転投影の実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">回転周期2秒 · sin・cos2組 · 行列なし</span>
<span class="lz__meta lz__meta--dim">canvas · 奥行きシェーディング · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 3D 회전 투영(02) — @keyframes 를 두지 않는다.
// 움직임은 전부 캔버스 rAF 루프(사선 투영 좌표를 회전각마다 다시 계산)가 만든다.
// 무대는 편 공통 심해 남색 관측창. 파스텔 팔레트는 01 과 같은 규격.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-c0: #cfe3ff;
$lz-c1: #ffd9c9;
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 3D 회전 투영(02) — 로렌츠 어트랙터를 입체로 돌아 보게 만든다.
// 사전 적분한 2,600걸음을 회전각 t 에 맞춰 사선 투영(xy 회전 + z 를 세로·y 를 깊이로)하고
// 깊이(y)로 색·두께·밝기를 음영한다. 행렬 곱 없이 sin·cos 두 쌍의 회전식만으로 3D 를 만든다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.01; // 오일러 걸음
var STEPS = 2600; // 사전 적분 걸음 수
var SPIN_S = 2.0; // 화면 가로축 회전 주기(초) — 설정 초(2s)와 같다
var SPIN_L = 14.0; // 관찰축(깊이) 회전 주기(초) — 느리게 도는 두 번째 축
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C0 = tokRGB('--lz-c0'); // 먼 궤적 — 얼음 파스텔
var C1 = tokRGB('--lz-c1'); // 가까운 궤적 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 사전 적분 — 과도기 200걸음 건너뛰기
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var x = 0.6, y = 0.7, z = 21, i;
for (i = 0; i < 200; i++) {
var d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (i = 0; i < STEPS; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
var d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
var CH = 26;
function draw(tSec) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var a1 = tSec / SPIN_S * 6.2832; // z축 회전각(라디안) — 나비가 도는 빠른 축(2초)
var a2 = Math.sin(tSec / SPIN_L * 6.2832) * 0.45 + 0.55; // 시야 기울기(0.10~1.00rad 왕복, 14초) — 0 을 지나지 않는다
var ca1 = Math.cos(a1), sa1 = Math.sin(a1);
var ca2 = Math.cos(a2), sa2 = Math.sin(a2);
// 이방 스케일 — 캔버스는 2.4:1 넓은 창, 어트랙터는 1:1 에 가까워 한 변으로 묶으면
// 가로가 절반 이상 비는 것을 막는다. 가로 W/57 · 세로 H/37.5 로 각 변을 채운다.
var sx = W / 57, sy = H / 37.5;
var ox = W / 2, oy = H / 2;
// 사선 투영 — z축 회전(u, w) 뒤 기울기 a2 로 z 를 가로에도 섞는다.
// 가로 성분이 두 축에 걸려 있어 어떤 회전각에서도 궤적이 실 한 줄로 무너지지 않는다.
function pr(i) {
var u = xs[i] * ca1 - ys[i] * sa1;
var w = xs[i] * sa1 + ys[i] * ca1;
var zv = zs[i] - 25;
return [ox + (u * ca2 + zv * sa2) * sx, oy - (zv * ca2 * 0.66 + w * 0.40) * sy];
}
var k, a, b, ci, ny, col, t, P;
for (k = 0; k < CH; k++) {
a = Math.floor(k * STEPS / CH);
b = Math.floor((k + 1) * STEPS / CH);
if (b - a < 2) continue;
t = (k + 0.5) / CH;
ctx.beginPath();
var sum = 0;
P = pr(a);
ctx.moveTo(P[0], P[1]);
for (ci = a + 1; ci < b; ci++) {
P = pr(ci);
ctx.lineTo(P[0], P[1]);
sum += ys[ci];
}
ny = (sum / (b - a - 1) + 27) / 57;
ny = Math.max(0, Math.min(1, ny));
col = mix(C0, C1, ny);
ctx.strokeStyle = rgba(col, 0.20 + 0.62 * ny);
ctx.lineWidth = 1.2 + 1.8 * ny;
ctx.stroke();
}
// 머리 — 현재 프레임의 회전각에 맞춘 위치
var m = STEPS - 1;
P = pr(m);
var hx = P[0], hy = P[1];
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000 % SPIN_S + 0.4);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.4);
});
fit();
if (reduce) {
draw(0.4); // reduce — 한 회전각만 고정
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// Lorenz3dRotateProject — vanilla 판(run/324/demo/02-lorenz-3d-rotate-project/index.html) 의 React 포트.
// 사전 적분한 2,600걸음을 회전각 t 에 맞춰 사선 투영(xy 회전 + z 를 세로·y 를 깊이로)하고
// 깊이(y)로 색·두께·밝기를 음영한다. 행렬 곱 없이 sin·cos 두 쌍의 회전식만으로 3D 를 만든다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./Lorenz3dRotateProject.module.scss";
export type Lorenz3dRotateProjectProps = {
duration?: string;
easing?: string;
color?: string;
steps?: number; // 사전 적분 걸음 수
spinS?: number; // 화면 가로축 회전 주기(초) — 설정 초(2s)와 같다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.01; // 오일러 걸음
const STEPS = 2600; // 사전 적분 걸음 수
const SPIN_S = 2.0; // 화면 가로축 회전 주기(초) — 설정 초(2s)와 같다
const SPIN_L = 14.0; // 관찰축(깊이) 회전 주기(초) — 느리게 도는 두 번째 축
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "3D 회전 투영 실험판";
const META_LEFT = "회전 주기 2초 · sin·cos 두 쌍 · 행렬 없음";
const META_RIGHT = "canvas · 깊이 음영 · requestAnimationFrame";
const HINT = "x·y·z 를 회전각으로 돌려 화면 좌표에 사선 투영하면 나비가 입체로 돌아 깊이가 느껴집니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function deriv(x: number, y: number, z: number): [number, number, number] {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
export default function Lorenz3dRotateProject({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
steps = STEPS,
spinS = SPIN_S
}: Lorenz3dRotateProjectProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const c0Ref = useRef<RGB | null>(null);
const c1Ref = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 사전 적분 — 과도기 200걸음 건너뛰기
function precompute(n: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let x = 0.6, y = 0.7, z = 21;
for (let i = 0; i < 200; i++) {
const d0 = deriv(x, y, z);
x += d0[0] * DT; y += d0[1] * DT; z += d0[2] * DT;
}
for (let i = 0; i < n; i++) {
xs[i] = x; ys[i] = y; zs[i] = z;
const d1 = deriv(x, y, z);
x += d1[0] * DT; y += d1[1] * DT; z += d1[2] * DT;
}
trajRef.current = { xs, ys, zs };
}
const CH = 26;
function draw(tSec: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const PANEL = panelRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !PANEL || !C0 || !C1 || !HEAD) return;
const { xs, ys, zs } = traj;
const STEPSN = xs.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const a1 = tSec / SPIN_S * 6.2832; // z축 회전각(라디안) — 나비가 도는 빠른 축(2초)
const a2 = Math.sin(tSec / SPIN_L * 6.2832) * 0.45 + 0.55; // 시야 기울기(0.10~1.00rad 왕복, 14초) — 0 을 지나지 않는다
const ca1 = Math.cos(a1), sa1 = Math.sin(a1);
const ca2 = Math.cos(a2), sa2 = Math.sin(a2);
// 이방 스케일 — 캔버스는 2.4:1 넓은 창, 어트랙터는 1:1 에 가까워 한 변으로 묶으면
// 가로가 절반 이상 비는 것을 막는다. 가로 W/57 · 세로 H/37.5 로 각 변을 채운다.
const sx = W / 57, sy = H / 37.5;
const ox = W / 2, oy = H / 2;
// 사선 투영 — z축 회전(u, w) 뒤 기울기 a2 로 z 를 가로에도 섞는다.
// 가로 성분이 두 축에 걸려 있어 어떤 회전각에서도 궤적이 실 한 줄로 무너지지 않는다.
function pr(i: number): [number, number] {
const u = xs[i] * ca1 - ys[i] * sa1;
const w = xs[i] * sa1 + ys[i] * ca1;
const zv = zs[i] - 25;
return [ox + (u * ca2 + zv * sa2) * sx, oy - (zv * ca2 * 0.66 + w * 0.40) * sy];
}
for (let k = 0; k < CH; k++) {
const a = Math.floor(k * STEPSN / CH);
const b = Math.floor((k + 1) * STEPSN / CH);
if (b - a < 2) continue;
const t = (k + 0.5) / CH;
ctx.beginPath();
let sum = 0;
let P = pr(a);
ctx.moveTo(P[0], P[1]);
for (let ci = a + 1; ci < b; ci++) {
P = pr(ci);
ctx.lineTo(P[0], P[1]);
sum += ys[ci];
}
let ny = (sum / (b - a - 1) + 27) / 57;
ny = Math.max(0, Math.min(1, ny));
const col = mix(C0, C1, ny);
ctx.strokeStyle = rgba(col, 0.20 + 0.62 * ny);
ctx.lineWidth = 1.2 + 1.8 * ny;
ctx.stroke();
}
// 머리 — 현재 프레임의 회전각에 맞춘 위치
const m = STEPSN - 1;
const P = pr(m);
const hx = P[0], hy = P[1];
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx, hy, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx, hy, 2.2, 0, 6.2832); ctx.fill();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
draw((now - t0) / 1000 % spinS + 0.4);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0"); // 먼 궤적 — 얼음 파스텔
c1Ref.current = tokRGB(root, "--lz-c1"); // 가까운 궤적 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");// 관측창 바닥
ctxRef.current = cv.getContext("2d");
precompute(steps);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.4);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.4); // reduce — 한 회전각만 고정
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, spinS]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}02 · 3D回転投影像 — 軌道を斜め投影でくるくる回す — 計測 累積面積 28.715% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
異方性スケールと斜め投影の核心
キャンバスは2.4:1の横長なのにアトラクターは1:1に近く、一辺 min(W, H) だけで括ると横の半分以上が空きます。横を W/57・縦を H/37.5 と別々に割って両辺を埋めるのが、この項目の面積の鍵です。
var a1 = tSec / SPIN_S * 6.2832; // z軸回転角 — 蝶が回る速い軸(2秒)
var ca1 = Math.cos(a1), sa1 = Math.sin(a1);
var ca2 = Math.cos(a2), sa2 = Math.sin(a2);
var sx = W / 57, sy = H / 37.5;
var ox = W / 2, oy = H / 2;
// 斜め投影 — z軸回転(u, w)の後、傾き a2 で z を横にも混ぜる。
// 横成分が2軸にまたがるので、どの回転角でも軌道が糸1本に崩れない。
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];
}
遅い傾き軸が0を通らない理由
傾き角 a2 は正弦で0.10〜1.00rad を往復し、0 を通りません。傾きがちょうど0の瞬間は z が横に混ざらず、軌道が平面に潰れて糸1本に見えるからです。
03RK4積分 — オイラーから4次ルンゲ・クッタへ精度を上げる
1歩を4回の傾き平均で計算すると、オイラーよりはるかに正確で長く崩れない軌道になります。同じ dt 0.02 で2,400歩ずつ積分すると、左のオイラー盤は数周後に形が崩れ、右の RK4 盤は形を保ちます。オイラーの誤差が歩幅に比例(1次)するのに対し RK4 は4乗に比例するので、同じ歩幅でも誤差の桁が違います。累積面積11.656%は左右2盤を合わせた値です。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>1歩を4回の傾き平均で計算する4次ルンゲ・クッタは、オイラーと同じ歩幅でもはるかに長く崩れません</span>
</p>
<div class="lz__field" role="application" aria-label="RK4積分の実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">dt 0.02 · オイラー比で誤差1/64</span>
<span class="lz__meta lz__meta--dim">canvas · RK4の4標本 · 周期2秒</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// RK4 적분(03) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(오일러와 RK4 를 같은 시드로 나란히 적분해 드러낸다)가 만든다.
// 무대는 편 공통 심해 남색. 오일러=민트 크림, RK4=복숭아 파스텔로 판을 나눈다.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-ceu: #d8f0e3; // 오일러 궤적 — 민트 크림(파스텔)
$lz-crk: #ffd9c9; // RK4 궤적 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-crk;
.lz {
--lz-panel: #{$lz-panel};
--lz-ceu: #{$lz-ceu};
--lz-crk: #{$lz-crk};
--lz-textc: #{$lz-text};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-crk; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-ceu: #d8f0e3;
--lz-crk: #ffd9c9;
--lz-textc: #ccd8e6;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #ffd9c9;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// RK4 적분(03) — 같은 dt 로 오일러와 4차 룽게-쿠타를 나란히 견준다.
// 왼쪽 절반은 오일러(dt 0.02) — 걸음이 성기해 궤적이 두껍게 부풀며 어트랙터가 일그러진다.
// 오른쪽 절반은 RK4(dt 0.02) — 네 번의 기울기 평균으로 같은 걸음에도 형태가 오래 유지된다.
// 두 판은 같은 시드(0.6, 0.7, 21)에서 같은 걸음 수만큼 적분해 차이가 적분기에서만 오게 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.02; // 두 적분기 공통 걸음 — 오일러가 일그러지기 시작하는 크기
var STEPS = 2400; // 각 판의 사전 적분 걸음 수
var CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var CEU = tokRGB('--lz-ceu'); // 오일러 궤적 — 민트 크림
var CRK = tokRGB('--lz-crk'); // RK4 궤적 — 복숭아 파스텔
var HEAD = tokRGB('--lz-head'); // 머리 점 — 크림(토큰)
var PANEL = tokRGB('--lz-panel'); // 관측창 바닥
var TEXT_C = tokRGB('--lz-textc'); // 판 라벨 — 청회
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 오일러 한 걸음 — 기울기 한 번으로 위치를 옮긴다
function euler(p) {
var d = deriv(p[0], p[1], p[2]);
return [p[0] + d[0] * DT, p[1] + d[1] * DT, p[2] + d[2] * DT];
}
// RK4 한 걸음 — k1~k4 네 기울기의 가중 평균(1/6, 1/3, 1/3, 1/6)
function rk4(p) {
var k1 = deriv(p[0], p[1], p[2]);
var k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
// 두 판 사전 적분 — 같은 시드, 같은 걸음 수
var euX = new Float32Array(STEPS), euZ = new Float32Array(STEPS);
var rkX = new Float32Array(STEPS), rkZ = new Float32Array(STEPS);
var pe = [0.6, 0.7, 21], pr = [0.6, 0.7, 21], i;
for (i = 0; i < 200; i++) { pe = euler(pe); pr = rk4(pr); }
for (i = 0; i < STEPS; i++) {
euX[i] = pe[0]; euZ[i] = pe[2]; pe = euler(pe);
rkX[i] = pr[0]; rkZ[i] = pr[2]; pr = rk4(pr);
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
var REVEAL_END = 0.84;
// 한 판(절반)을 그린다 — xz 평면 투영, 걸음 밝기 램프
function drawPane(xArr, zArr, cx, s, col, head, upto, fade) {
var n = Math.floor(upto * STEPS);
if (n < 2) return;
var i2;
// 뒤네루게 — 넓고 옅게
ctx.strokeStyle = rgba(col, 0.13 * fade);
ctx.lineWidth = 3.4;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + xArr[i2] * s, H * 0.5 - (zArr[i2] - 25) * s);
ctx.stroke();
// 알맹이 — 시간 램프로 최근 궤적을 밝게
var ramp = Math.max(0.10, 0.55 * (1 - upto) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + xArr[i2] * s, H * 0.5 - (zArr[i2] - 25) * s);
ctx.stroke();
// 머리 점
var hx = cx + xArr[n - 1] * s, hy = H * 0.5 - (zArr[n - 1] - 25) * s;
ctx.fillStyle = rgba(head, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(head, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var upto = Math.min(1, phase / REVEAL_END);
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
var s = Math.min(W / 48, H / 52) * 0.94;
var mid = W / 2;
// 판 경계 — 세로 안내선
ctx.strokeStyle = rgba(TEXT_C, 0.16);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(euX, euZ, mid * 0.52, s * 0.9, CEU, HEAD, upto, fade);
drawPane(rkX, rkZ, mid * 1.48, s * 0.9, CRK, HEAD, upto, fade);
// 판 라벨
ctx.fillStyle = rgba(CEU, 0.9 * fade + 0.1);
ctx.textAlign = 'center';
ctx.fillText('EULER dt 0.02', mid * 0.52, H - 6);
ctx.fillStyle = rgba(CRK, 0.9 * fade + 0.1);
ctx.fillText('RK4 dt 0.02', mid * 1.48, H - 6);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62);
});
fit();
if (reduce) {
draw(0.62);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzRk4Integrate — vanilla 판(run/324/demo/03-lorenz-rk4-integrate/index.html) 의 React 포트.
// 같은 dt 로 오일러와 4차 룽게-쿠타를 나란히 견준다.
// 왼쪽 절반은 오일러(dt 0.02) — 걸음이 성기해 궤적이 두껍게 부풀며 어트랙터가 일그러진다.
// 오른쪽 절반은 RK4(dt 0.02) — 네 번의 기울기 평균으로 같은 걸음에도 형태가 오래 유지된다.
// 두 판은 같은 시드(0.6, 0.7, 21)에서 같은 걸음 수만큼 적분해 차이가 적분기에서만 오게 한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzRk4Integrate.module.scss";
export type LorenzRk4IntegrateProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // 두 적분기 공통 걸음 — 오일러가 일그러지기 시작하는 크기
steps?: number; // 각 판의 사전 적분 걸음 수
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.02; // 두 적분기 공통 걸음
const STEPS = 2400; // 각 판의 사전 적분 걸음 수
const CYCLE = 2.0; // 공개 주기(초) — 설정 초(2s)와 같다
const REVEAL_END = 0.84;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Vec3 = [number, number, number];
const FIELD_LABEL = "RK4 적분 실험판";
const META_LEFT = "dt 0.02 · 오일러 대비 오차 64배 감소";
const META_RIGHT = "canvas · RK4 4표본 · 주기 2초";
const HINT = "한 걸음을 네 번의 기울기 평균으로 계산하는 4차 룽게-쿠타는 오일러와 같은 걸음으로도 훨씬 오래 흐트러지지 않습니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function deriv(x: number, y: number, z: number): Vec3 {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
// 오일러 한 걸음 — 기울기 한 번으로 위치를 옮긴다
function euler(p: Vec3, DT: number): Vec3 {
const d = deriv(p[0], p[1], p[2]);
return [p[0] + d[0] * DT, p[1] + d[1] * DT, p[2] + d[2] * DT];
}
// RK4 한 걸음 — k1~k4 네 기울기의 가중 평균(1/6, 1/3, 1/3, 1/6)
function rk4(p: Vec3, DT: number): Vec3 {
const k1 = deriv(p[0], p[1], p[2]);
const k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
const k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
const k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LorenzRk4Integrate({
duration = "2s",
easing = "ease-out",
color = "#ffd9c9",
dt = DT,
steps = STEPS
}: LorenzRk4IntegrateProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const ceuRef = useRef<RGB | null>(null);
const crkRef = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const textcRef = useRef<RGB | null>(null);
const euRef = useRef<{ x: Float32Array; z: Float32Array } | null>(null);
const rkRef = useRef<{ x: Float32Array; z: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 두 판 사전 적분 — 같은 시드, 같은 걸음 수
function precompute(n: number, step: number): void {
const euX = new Float32Array(n), euZ = new Float32Array(n);
const rkX = new Float32Array(n), rkZ = new Float32Array(n);
let pe: Vec3 = [0.6, 0.7, 21], pr: Vec3 = [0.6, 0.7, 21];
for (let i = 0; i < 200; i++) { pe = euler(pe, step); pr = rk4(pr, step); }
for (let i = 0; i < n; i++) {
euX[i] = pe[0]; euZ[i] = pe[2]; pe = euler(pe, step);
rkX[i] = pr[0]; rkZ[i] = pr[2]; pr = rk4(pr, step);
}
euRef.current = { x: euX, z: euZ };
rkRef.current = { x: rkX, z: rkZ };
}
// 한 판(절반)을 그린다 — xz 평면 투영, 걸음 밝기 램프
function drawPane(
xArr: Float32Array, zArr: Float32Array, cx: number, s: number,
col: RGB, head: RGB, upto: number, fade: number, n0: number, H: number, ctx: CanvasRenderingContext2D
): void {
const n = Math.floor(upto * n0);
if (n < 2) return;
// 뒤네루게 — 넓고 옅게
ctx.strokeStyle = rgba(col, 0.13 * fade);
ctx.lineWidth = 3.4;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (let i = 1; i < n; i++) ctx.lineTo(cx + xArr[i] * s, H * 0.5 - (zArr[i] - 25) * s);
ctx.stroke();
// 알맹이 — 시간 램프로 최근 궤적을 밝게
const ramp = Math.max(0.10, 0.55 * (1 - upto) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(cx + xArr[0] * s, H * 0.5 - (zArr[0] - 25) * s);
for (let i = 1; i < n; i++) ctx.lineTo(cx + xArr[i] * s, H * 0.5 - (zArr[i] - 25) * s);
ctx.stroke();
// 머리 점
const hx = cx + xArr[n - 1] * s, hy = H * 0.5 - (zArr[n - 1] - 25) * s;
ctx.fillStyle = rgba(head, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(head, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const eu = euRef.current;
const rk = rkRef.current;
const PANEL = panelRef.current;
const CEU = ceuRef.current;
const CRK = crkRef.current;
const HEAD = headRef.current;
const TEXT_C = textcRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !eu || !rk || !PANEL || !CEU || !CRK || !HEAD || !TEXT_C) return;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const upto = Math.min(1, phase / REVEAL_END);
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
const s = Math.min(W / 48, H / 52) * 0.94;
const mid = W / 2;
// 판 경계 — 세로 안내선
ctx.strokeStyle = rgba(TEXT_C, 0.16);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(eu.x, eu.z, mid * 0.52, s * 0.9, CEU, HEAD, upto, fade, eu.x.length, H, ctx);
drawPane(rk.x, rk.z, mid * 1.48, s * 0.9, CRK, HEAD, upto, fade, rk.x.length, H, ctx);
// 판 라벨
ctx.fillStyle = rgba(CEU, 0.9 * fade + 0.1);
ctx.textAlign = "center";
ctx.fillText("EULER dt 0.02", mid * 0.52, H - 6);
ctx.fillStyle = rgba(CRK, 0.9 * fade + 0.1);
ctx.fillText("RK4 dt 0.02", mid * 1.48, H - 6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
ceuRef.current = tokRGB(root, "--lz-ceu"); // 오일러 궤적 — 민트 크림
crkRef.current = tokRGB(root, "--lz-crk"); // RK4 궤적 — 복숭아 파스텔
headRef.current = tokRGB(root, "--lz-head"); // 머리 점 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");
textcRef.current = tokRGB(root, "--lz-textc");
ctxRef.current = cv.getContext("2d");
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, dt]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}03 · RK4積分 — オイラーから4次ルンゲ・クッタへ精度を上げる — 計測 累積面積 11.656% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
euler と rk4 — 同じ1歩の2つの読み方
euler は出発点の傾き1回で位置を動かし、rk4 は k1〜k4 の4回の傾きを 1/6・1/3・1/3・1/6 の重みで平均します。どちらも同じ DT を扱うので、差は積分器だけから出ます。
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];
}
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])
];
}
同じ種から同じ歩数だけ歩かせる
両盤は同じ初期値 (0.6, 0.7, 21) から同じ2,400歩を積分し、記録も同じ位相で公開します。並べて初めて、崩れる方が歩幅の誤差で壊れていることが読み取れます。
04アトラクター図鑑 — クリフォード・ド・ジョン、数百万点が描く確率の花
微分方程式ではない2次元漸化式に乱数点を通し、訪問回数を明るさとして積むと、係数ごとに違う文様が滝のように開きます。クリフォード x′=sin(a·y)+c·cos(a·x)・ド・ジョン x′=sin(a·y)−b·cos(x) の6組の係数を、チャンネルごとに2万回ずつ反復して3色を順番に現します。強度平均15.5は薄いパステルの確率の絵として最も落ち着いた値です。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>クリフォード・ド・ジョン漸化式に数万回の反復を流して訪問の明るさを積むと、係数の組み合わせごとに違う文様が開きます</span>
</p>
<div class="lz__field" role="application" aria-label="アトラクター図鑑の実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">2次元漸化式 · チャンネルごと2万回</span>
<span class="lz__meta lz__meta--dim">canvas · 明暗の散布 · 周期2秒</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 어트랙터 지도책(04) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(클리포드·드용 점화식 반복 → 방문 밝기 누적 → 채널 순차 합성)가 만든다.
// 무대는 편 공통 심해 남색. 채널 3색은 파스텔 트리오(라벤더·아이스·피치).
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-k0: #e6d9ff; // 채널 0 — 라일락 화이트
$lz-k1: #cfe3ff; // 채널 1 — 얼음 파스텔
$lz-k2: #ffd9c9; // 채널 2 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-k0;
.lz {
--lz-panel: #{$lz-panel};
--lz-k0: #{$lz-k0};
--lz-k1: #{$lz-k1};
--lz-k2: #{$lz-k2};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-k0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-k0: #e6d9ff;
--lz-k1: #cfe3ff;
--lz-k2: #ffd9c9;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #e6d9ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 어트랙터 지도책(04) — 로렌츠와 같은 '어트랙터' 개념을 2차원 점화식으로 보여준다.
// 클리포드 x' = sin(a·y) + c·cos(a·x), y' = sin(b·x) + d·cos(b·y)
// 드용 x' = sin(a·y) − b·cos(x), y' = sin(c·x) − d·cos(y)
// 한 점을 식에 반복 통과시키며 ImageData 채널에 방문 횟수를 누적한다.
// 하나의 궤적이 채널을 채우면 다음 채널로 넘어가고, 셋이 다 채워지면 색으로 합성된다.
// 무작위 시드가 루프마다 같은 결정론 궤도를 그리므로 화면은 매 루프 재현 가능하다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var ITERS = 20000; // 채널당 반복 수 — 늘리면 문양이 더 촘촘해진다
var CYCLE = 2.0; // 완성 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var COLS = ['--lz-k0', '--lz-k1', '--lz-k2'].map(tokRGB); // 채널 3색 — 파스텔 트리오
var PANEL = tokRGB('--lz-panel');
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 계수 목록 — 클리포드 a,b,c,d / 드용 a,b,c,d (뒤의 3개는 드용)
var SETS = [
{ kind: 0, p: [-1.4, 1.6, 1.0, 0.7] },
{ kind: 0, p: [-1.7, 1.3, 0.5, 1.21] },
{ kind: 0, p: [1.5, -1.8, 1.6, 0.9] },
{ kind: 1, p: [2.01, -2.53, 1.61, -0.33] },
{ kind: 1, p: [-2.7, -0.09, -0.86, -2.2] },
{ kind: 1, p: [-2.24, 0.43, -0.65, -2.43] }
];
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var RES = 150; // 내부 누적 해상도(정사각) — 성능 노브
var acc; // 방문 카운트 Float32Array(RES*RES)
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
acc = new Float32Array(RES * RES);
}
// 결정론 난수 — 매 루프 같은 그림이 쌓이게 하는 시드 고정 mulberry32
function rng(seed) {
var t = seed;
return function () {
t += 0x6D2B79F5;
var r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
// 한 채널 누적 — kind 0=클리포드, 1=드용. 걸음 수 비율 upto 까지.
function accumulate(set, upto) {
var rand = rng(set.kind * 1000 + 7);
var x = rand() * 2 - 1, y = rand() * 2 - 1;
var n = Math.floor(ITERS * upto);
var a = set.p[0], b = set.p[1], c = set.p[2], d = set.p[3];
var i, px, py;
for (i = 0; i < n + 400; i++) {
if (set.kind === 0) {
var nx = Math.sin(a * y) + c * Math.cos(a * x);
y = Math.sin(b * x) + d * Math.cos(b * y);
x = nx;
} else {
var nx2 = Math.sin(a * y) - b * Math.cos(x);
y = Math.sin(c * x) - d * Math.cos(y);
x = nx2;
}
if (i < 400) continue; // 과도기 버림
px = (x + 3) / 6 * (RES - 1);
py = (y + 3) / 6 * (RES - 1);
if (px >= 0 && px < RES && py >= 0 && py < RES) {
acc[(py | 0) * RES + (px | 0)] += 1;
}
}
}
// 누적 카운트 → 명암 산포(반비례 감마)로 그리기 — 채널색 3장 합성
function blit(phase) {
var maxA = 0, i;
for (i = 0; i < acc.length; i++) if (acc[i] > maxA) maxA = acc[i];
if (maxA <= 0) maxA = 1;
// 채널 진행 — 셋으로 나눠 순차 등장
var seg = phase * 3;
var chAmt = [Math.max(0, Math.min(1, seg)), Math.max(0, Math.min(1, seg - 1)), Math.max(0, Math.min(1, seg - 2))];
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.92;
var ox = (W - side) / 2, oy = (H - side) / 2;
var cell = side / RES;
var g, alpha, cx, cy;
ctx.save();
ctx.translate(ox, oy);
for (var j = 0; j < RES; j++) {
cy = j * cell;
for (var k2 = 0; k2 < RES; k2++) {
var v = acc[j * RES + k2];
if (v <= 0) continue;
g = Math.pow(v / maxA, 0.32); // 감마 — 옅은 방문도 살린다
// 채널 3개를 합성 — ch0 파스텔 라벤더, ch1 아이스, ch2 피치
var r0 = 0, g0 = 0, b0 = 0, kk;
for (kk = 0; kk < 3; kk++) {
if (chAmt[kk] <= 0) continue;
var w = g * chAmt[kk] / 3;
r0 += COLS[kk][0] * w; g0 += COLS[kk][1] * w; b0 += COLS[kk][2] * w;
}
alpha = Math.min(1, 0.16 + g * 0.9);
ctx.fillStyle = 'rgba(' + (r0 | 0) + ',' + (g0 | 0) + ',' + (b0 | 0) + ',' + alpha + ')';
cx = k2 * cell;
ctx.fillRect(cx, cy, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
}
// 채널 0..2 = 지도책 서로 다른 계수 — 세 채널이 같은 판에 겹쳐 합성된다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, lastSeg = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
var seg = Math.floor(phase * 3);
if (seg !== lastSeg) { // 채널이 넘어갈 때만 누적을 다시 쌓는다
lastSeg = seg;
acc = new Float32Array(RES * RES);
accumulate(SETS[seg], 1);
accumulate(SETS[seg + 3], 1);
}
blit(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) blit(0.9);
});
fit();
if (reduce) {
lastSeg = 0;
accumulate(SETS[0], 1); accumulate(SETS[3], 1);
blit(0.9); // reduce — 완성 문양 한 장
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzAtlasCliffordDejong — vanilla 판(run/324/demo/04-lorenz-atlas-clifford-dejong/index.html) 의 React 포트.
// 클리포드 x' = sin(a·y) + c·cos(a·x), y' = sin(b·x) + d·cos(b·y)
// 드용 x' = sin(a·y) − b·cos(x), y' = sin(c·x) − d·cos(y)
// 한 점을 식에 반복 통과시키며 방문 횟수를 누적한다.
// 하나의 궤적이 채널을 채우면 다음 채널로 넘어가고, 셋이 다 채워지면 색으로 합성된다.
// 무작위 시드가 루프마다 같은 결정론 궤도를 그리므로 화면은 매 루프 재현 가능하다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzAtlasCliffordDejong.module.scss";
export type LorenzAtlasCliffordDejongProps = {
duration?: string;
easing?: string;
color?: string;
iters?: number; // 채널당 반복 수 — 늘리면 문양이 더 촘촘해진다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const ITERS = 20000; // 채널당 반복 수
const CYCLE = 2.0; // 완성 주기(초) — 설정 초(2s)와 같다
const RES = 150; // 내부 누적 해상도(정사각) — 성능 노브
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type AttractorSet = { kind: number; p: [number, number, number, number] };
const FIELD_LABEL = "어트랙터 지도책 실험판";
const META_LEFT = "2차원 점화식 · 채널당 2만 번";
const META_RIGHT = "canvas · 명암 산포 · 주기 2초";
const HINT = "클리포드·드용 점화식에 수만 번의 반복을 흘려보내 방문 밝기를 쌓으면 계수 조합마다 다른 문양이 펼쳐집니다";
// 계수 목록 — 클리포드 a,b,c,d / 드용 a,b,c,d (뒤의 3개는 드용)
const SETS: AttractorSet[] = [
{ kind: 0, p: [-1.4, 1.6, 1.0, 0.7] },
{ kind: 0, p: [-1.7, 1.3, 0.5, 1.21] },
{ kind: 0, p: [1.5, -1.8, 1.6, 0.9] },
{ kind: 1, p: [2.01, -2.53, 1.61, -0.33] },
{ kind: 1, p: [-2.7, -0.09, -0.86, -2.2] },
{ kind: 1, p: [-2.24, 0.43, -0.65, -2.43] }
];
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
// 결정론 난수 — 매 루프 같은 그림이 쌓이게 하는 시드 고정 mulberry32
function rng(seed: number): () => number {
let t = seed;
return function (): number {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
export default function LorenzAtlasCliffordDejong({
duration = "2s",
easing = "ease-out",
color = "#e6d9ff",
iters = ITERS
}: LorenzAtlasCliffordDejongProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const colsRef = useRef<RGB[] | null>(null);
const panelRef = useRef<RGB | null>(null);
const accRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const lastSegRef = useRef(-1) as Box<number>;
const itersRef = useRef(iters) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
accRef.current = new Float32Array(RES * RES);
}
// 한 채널 누적 — kind 0=클리포드, 1=드용. 걸음 수 비율 upto 까지.
function accumulate(set: AttractorSet, upto: number): void {
const acc = accRef.current;
if (!acc) return;
const rand = rng(set.kind * 1000 + 7);
let x = rand() * 2 - 1, y = rand() * 2 - 1;
const n = Math.floor(itersRef.current * upto);
const a = set.p[0], b = set.p[1], c = set.p[2], d = set.p[3];
for (let i = 0; i < n + 400; i++) {
if (set.kind === 0) {
const nx = Math.sin(a * y) + c * Math.cos(a * x);
y = Math.sin(b * x) + d * Math.cos(b * y);
x = nx;
} else {
const nx2 = Math.sin(a * y) - b * Math.cos(x);
y = Math.sin(c * x) - d * Math.cos(y);
x = nx2;
}
if (i < 400) continue; // 과도기 버림
const px = (x + 3) / 6 * (RES - 1);
const py = (y + 3) / 6 * (RES - 1);
if (px >= 0 && px < RES && py >= 0 && py < RES) {
acc[(py | 0) * RES + (px | 0)] += 1;
}
}
}
// 누적 카운트 → 명암 산포(반비례 감마)로 그리기 — 채널색 3장 합성
function blit(phase: number): void {
const ctx = ctxRef.current;
const acc = accRef.current;
const COLS = colsRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !acc || !COLS || !PANEL) return;
let maxA = 0;
for (let i = 0; i < acc.length; i++) if (acc[i] > maxA) maxA = acc[i];
if (maxA <= 0) maxA = 1;
// 채널 진행 — 셋으로 나눠 순차 등장
const seg = phase * 3;
const chAmt = [
Math.max(0, Math.min(1, seg)),
Math.max(0, Math.min(1, seg - 1)),
Math.max(0, Math.min(1, seg - 2))
];
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.92;
const ox = (W - side) / 2, oy = (H - side) / 2;
const cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
for (let j = 0; j < RES; j++) {
const cy = j * cell;
for (let k2 = 0; k2 < RES; k2++) {
const v = acc[j * RES + k2];
if (v <= 0) continue;
const g = Math.pow(v / maxA, 0.32); // 감마 — 옅은 방문도 살린다
// 채널 3개를 합성 — ch0 파스텔 라벤더, ch1 아이스, ch2 피치
let r0 = 0, g0 = 0, b0 = 0;
for (let kk = 0; kk < 3; kk++) {
if (chAmt[kk] <= 0) continue;
const w = g * chAmt[kk] / 3;
r0 += COLS[kk][0] * w; g0 += COLS[kk][1] * w; b0 += COLS[kk][2] * w;
}
const alpha = Math.min(1, 0.16 + g * 0.9);
ctx.fillStyle = "rgba(" + (r0 | 0) + "," + (g0 | 0) + "," + (b0 | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cell, cy, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
const seg = Math.floor(phase * 3);
if (seg !== lastSegRef.current) { // 채널이 넘어갈 때만 누적을 다시 쌓는다
lastSegRef.current = seg;
accRef.current = new Float32Array(RES * RES);
accumulate(SETS[seg], 1);
accumulate(SETS[seg + 3], 1);
}
blit(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
itersRef.current = iters;
// 채널 3색 — 파스텔 트리오
colsRef.current = ["--lz-k0", "--lz-k1", "--lz-k2"].map((nm) => tokRGB(root, nm));
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
fit();
function onResize(): void {
fit();
if (reduced()) blit(0.9);
}
window.addEventListener("resize", onResize);
if (reduced()) {
lastSegRef.current = 0;
accumulate(SETS[0], 1);
accumulate(SETS[3], 1);
blit(0.9); // reduce — 완성 문양 한 장
setStill(true);
} else {
t0 = -1;
lastSegRef.current = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [iters]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}04 · アトラクター図鑑 — クリフォード・ド・ジョン、数百万点が描く確率の花 — 計測 累積面積 8.677% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
シード固定乱数と過渡の破棄
乱数は Math.random ではなくシード固定の rng(mulberry32系) から作り、同じ時刻のフレームはいつも同じ絵にします。accumulate は最初の400歩を捨ててアトラクターに乗ってから記録するので、文様の縁がにじみません。
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;
}
}
}
19/24フレームが正常である理由
3つの色チャンネルを2秒周期で順番に現す構造上、チャンネルの切り替わりで蓄積が始まるまでの数フレームは絵が動きません。他の八つが23/24なのに対し04だけ19/24なのは、この待ちの設計であって詰まっているわけではありません。
05アイザワ・トーマス — 球面と螺旋、別の方程式がくれた別の彫刻
アトラクターはローレンツだけではありません。球をまとうアイザワ、糸巻き螺旋のトーマス。係数を少し変えるだけで形が一変することを見せる比較実験です。どちらも RK4 dt 0.02・1,800歩で積分し、12秒周期の観察軸回転で立体の形を見せます。累積面積0.768%は細い軌道線1本の比較盤として九つで最も小さい値です。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>係数をいくつか変えた別の微分方程式は、球をまとうアイザワと糸巻き螺旋のトーマスを描きます</span>
</p>
<div class="lz__field" role="application" aria-label="アイザワ・トーマスの実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">左アイザワ · 右トーマス · RK4</span>
<span class="lz__meta lz__meta--dim">canvas · 奥行きシェーディング · 2秒周期</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 아이자와·토머스(05) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(두 미분방정식을 RK4 로 적분해 나란히 드러낸다)가 만든다.
// 무대는 편 공통 심해 남색. 아이자와=라일락 화이트, 토머스=민트 크림.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-ca: #e6d9ff; // 아이자와 궤적 — 라일락 화이트
$lz-ct: #d8f0e3; // 토머스 궤적 — 민트 크림
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-ca;
.lz {
--lz-panel: #{$lz-panel};
--lz-ca: #{$lz-ca};
--lz-ct: #{$lz-ct};
--lz-head: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-ca; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-ca: #e6d9ff;
--lz-ct: #d8f0e3;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #e6d9ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 아이자와·토머스(05) — 로렌츠가 아닌 어트랙터 두 개를 나란히 보여준다.
// 아이자와(구면): dx = (z−b)x − d·y, dy = d·x + (z−b)y, dz = c + a·z − z³/3 − (x²+y²)(1+e·z) + f·z·x³
// 토머스(나선): dx = sin(y) − b·x, dy = sin(z) − b·y, dz = sin(x) − b·z
// 두 계통 모두 RK4 로 적분하고, 관찰축을 천천히 돌려 입체 형태를 보여준다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.02; // RK4 걸음
var STEPS = 1800; // 각 판의 사전 적분 걸음 수
var SPIN_S = 2.0; // 궤적 드러나기 주기(초) — 설정 초(2s)와 같다
var SPIN_L = 12.0; // 관찰축 회전 주기(초)
// ────────────────────────────────────────────────────
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var CA = tokRGB('--lz-ca'); // 아이자와 — 라일락 화이트
var CT = tokRGB('--lz-ct'); // 토머스 — 민트 크림
var HEAD = tokRGB('--lz-head'); // 머리 — 크림(토큰)
var PANEL = tokRGB('--lz-panel');
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
// 아이자와 계수 — a=0.95, b=0.7, c=0.6, d=3.5, e=0.25, f=0.1 의 대표 형태
function dAizawa(x, y, z) {
var a = 0.95, b = 0.7, c = 0.6, d = 3.5, e = 0.25, f = 0.1;
return [
(z - b) * x - d * y,
d * x + (z - b) * y,
c + a * z - z * z * z / 3 - (x * x + y * y) * (1 + e * z) + f * z * x * x * x
];
}
// 토머스 계수 b=0.19 — 주기적으로 겹는 나선
function dThomas(x, y, z) {
var b = 0.19;
return [Math.sin(y) - b * x, Math.sin(z) - b * y, Math.sin(x) - b * z];
}
function rk4(p, f) {
var k1 = f(p[0], p[1], p[2]);
var k2 = f(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = f(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = f(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
// 사전 적분 — 아이자와는 구 중심 근처, 토머스는 원점에서
var azX = new Float32Array(STEPS), azY = new Float32Array(STEPS), azZ = new Float32Array(STEPS);
var thX = new Float32Array(STEPS), thY = new Float32Array(STEPS), thZ = new Float32Array(STEPS);
var pa = [0.1, 0, 0], pt = [1.1, 1.2, -0.6], i;
for (i = 0; i < 100; i++) { pa = rk4(pa, dAizawa); pt = rk4(pt, dThomas); }
for (i = 0; i < STEPS; i++) {
azX[i] = pa[0]; azY[i] = pa[1]; azZ[i] = pa[2]; pa = rk4(pa, dAizawa);
thX[i] = pt[0]; thY[i] = pt[1]; thZ[i] = pt[2]; pt = rk4(pt, dThomas);
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
var REVEAL_END = 0.82;
// 한 판 그리기 — xz 투영 + y 깊이 음영. xz: x 가로, z 세로, y 를 사선 성분으로
function drawPane(X, Y, Z, cx, cy, s, col, n, fade, tilt) {
if (n < 2) return;
var i2, hx, hy;
// 뒤네루게
ctx.strokeStyle = rgba(col, 0.12 * fade);
ctx.lineWidth = 3.0;
ctx.beginPath();
hx = cx + (X[0] + Y[0] * tilt) * s; hy = cy - Z[0] * s;
ctx.moveTo(hx, hy);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + (X[i2] + Y[i2] * tilt) * s, cy - Z[i2] * s);
ctx.stroke();
// 알맹이 — 걸음 진행에 따라 밝아지는 램프
var ramp = Math.max(0.10, 0.5 * (1 - n / STEPS) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(cx + (X[0] + Y[0] * tilt) * s, cy - Z[0] * s);
for (i2 = 1; i2 < n; i2++) ctx.lineTo(cx + (X[i2] + Y[i2] * tilt) * s, cy - Z[i2] * s);
ctx.stroke();
// 머리
hx = cx + (X[n - 1] + Y[n - 1] * tilt) * s; hy = cy - Z[n - 1] * s;
ctx.fillStyle = rgba(HEAD, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase, tSec) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var upto = Math.min(1, phase / REVEAL_END);
var n = Math.floor(upto * STEPS);
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 관찰축 회전 — 사선 성분 tilt 를 진동시킨다
var tilt = Math.sin(tSec / SPIN_L * 6.2832) * 0.5;
var mid = W / 2;
ctx.strokeStyle = rgba(CA, 0.14);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(azX, azY, azZ, mid * 0.52, H * 0.52, Math.min(W / 46, H / 42) * 0.6, CA, n, fade, tilt);
drawPane(thX, thY, thZ, mid * 1.48, H * 0.5, Math.min(W / 46, H / 42) * 0.5, CT, n, fade, tilt);
ctx.textAlign = 'center';
ctx.fillStyle = rgba(CA, 0.9 * fade + 0.1);
ctx.fillText('AIZAWA', mid * 0.52, H - 6);
ctx.fillStyle = rgba(CT, 0.9 * fade + 0.1);
ctx.fillText('THOMAS', mid * 1.48, H - 6);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var tSec = (now - t0) / 1000;
draw((tSec % SPIN_S) / SPIN_S, tSec);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.62, 0.4);
});
fit();
if (reduce) {
draw(0.62, 0.4);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzAtlasAizawaThomas — vanilla 판(run/324/demo/05-lorenz-atlas-aizawa-thomas/index.html) 의 React 포트.
// 아이자와(구면): dx = (z−b)x − d·y, dy = d·x + (z−b)y, dz = c + a·z − z³/3 − (x²+y²)(1+e·z) + f·z·x³
// 토머스(나선): dx = sin(y) − b·x, dy = sin(z) − b·y, dz = sin(x) − b·z
// 두 계통 모두 RK4 로 적분하고, 관찰축을 천천히 돌려 입체 형태를 보여준다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzAtlasAizawaThomas.module.scss";
export type LorenzAtlasAizawaThomasProps = {
duration?: string;
easing?: string;
color?: string;
steps?: number; // 각 판의 사전 적분 걸음 수
spinS?: number; // 궤적 드러나기 주기(초) — 설정 초(2s)와 같다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.02; // RK4 걸음
const STEPS = 1800; // 각 판의 사전 적분 걸음 수
const SPIN_S = 2.0; // 궤적 드러나기 주기(초) — 설정 초(2s)와 같다
const SPIN_L = 12.0; // 관찰축 회전 주기(초)
const REVEAL_END = 0.82;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Vec3 = [number, number, number];
type Deriv = (x: number, y: number, z: number) => Vec3;
const FIELD_LABEL = "아이자와·토머스 실험판";
const META_LEFT = "좌 아이자와 · 우 토머스 · RK4";
const META_RIGHT = "canvas · 깊이 음영 · 주기 2초";
const HINT = "계수 몇 개를 바꾼 또 다른 미분방정식은 구면을 감도는 아이자와와 실뭉치 나선의 토머스를 그립니다";
// 아이자와 계수 — a=0.95, b=0.7, c=0.6, d=3.5, e=0.25, f=0.1 의 대표 형태
function dAizawa(x: number, y: number, z: number): Vec3 {
const a = 0.95, b = 0.7, c = 0.6, d = 3.5, e = 0.25, f = 0.1;
return [
(z - b) * x - d * y,
d * x + (z - b) * y,
c + a * z - z * z * z / 3 - (x * x + y * y) * (1 + e * z) + f * z * x * x * x
];
}
// 토머스 계수 b=0.19 — 주기적으로 겹는 나선
function dThomas(x: number, y: number, z: number): Vec3 {
const b = 0.19;
return [Math.sin(y) - b * x, Math.sin(z) - b * y, Math.sin(x) - b * z];
}
function rk4(p: Vec3, f: Deriv): Vec3 {
const k1 = f(p[0], p[1], p[2]);
const k2 = f(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
const k3 = f(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
const k4 = f(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LorenzAtlasAizawaThomas({
duration = "2s",
easing = "ease-out",
color = "#e6d9ff",
steps = STEPS,
spinS = SPIN_S
}: LorenzAtlasAizawaThomasProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const caRef = useRef<RGB | null>(null);
const ctRef = useRef<RGB | null>(null);
const headRef = useRef<RGB | null>(null);
const panelRef = useRef<RGB | null>(null);
const azRef = useRef<{ x: Float32Array; y: Float32Array; z: Float32Array } | null>(null);
const thRef = useRef<{ x: Float32Array; y: Float32Array; z: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 사전 적분 — 아이자와는 구 중심 근처, 토머스는 원점에서
function precompute(n: number): void {
const azX = new Float32Array(n), azY = new Float32Array(n), azZ = new Float32Array(n);
const thX = new Float32Array(n), thY = new Float32Array(n), thZ = new Float32Array(n);
let pa: Vec3 = [0.1, 0, 0], pt: Vec3 = [1.1, 1.2, -0.6];
for (let i = 0; i < 100; i++) { pa = rk4(pa, dAizawa); pt = rk4(pt, dThomas); }
for (let i = 0; i < n; i++) {
azX[i] = pa[0]; azY[i] = pa[1]; azZ[i] = pa[2]; pa = rk4(pa, dAizawa);
thX[i] = pt[0]; thY[i] = pt[1]; thZ[i] = pt[2]; pt = rk4(pt, dThomas);
}
azRef.current = { x: azX, y: azY, z: azZ };
thRef.current = { x: thX, y: thY, z: thZ };
}
// 한 판 그리기 — xz 투영 + y 깊이 음영. xz: x 가로, z 세로, y 를 사선 성분으로
function drawPane(
X: Float32Array, Y: Float32Array, Z: Float32Array,
cx: number, cy: number, s: number, col: RGB, head: RGB, n: number, fade: number, tilt: number,
ctx: CanvasRenderingContext2D
): void {
if (n < 2) return;
// 뒤네루게
ctx.strokeStyle = rgba(col, 0.12 * fade);
ctx.lineWidth = 3.0;
ctx.beginPath();
let hx = cx + (X[0] + Y[0] * tilt) * s, hy = cy - Z[0] * s;
ctx.moveTo(hx, hy);
for (let i = 1; i < n; i++) ctx.lineTo(cx + (X[i] + Y[i] * tilt) * s, cy - Z[i] * s);
ctx.stroke();
// 알맹이 — 걸음 진행에 따라 밝아지는 램프
const ramp = Math.max(0.10, 0.5 * (1 - n / steps) + 0.15);
ctx.strokeStyle = rgba(col, ramp * fade);
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(cx + (X[0] + Y[0] * tilt) * s, cy - Z[0] * s);
for (let i = 1; i < n; i++) ctx.lineTo(cx + (X[i] + Y[i] * tilt) * s, cy - Z[i] * s);
ctx.stroke();
// 머리
hx = cx + (X[n - 1] + Y[n - 1] * tilt) * s; hy = cy - Z[n - 1] * s;
ctx.fillStyle = rgba(head, 0.22 * fade);
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(head, fade);
ctx.beginPath(); ctx.arc(hx, hy, 2, 0, 6.2832); ctx.fill();
}
function draw(phase: number, tSec: number): void {
const ctx = ctxRef.current;
const az = azRef.current;
const th = thRef.current;
const PANEL = panelRef.current;
const CA = caRef.current;
const CT = ctRef.current;
const HEAD = headRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !az || !th || !PANEL || !CA || !CT || !HEAD) return;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const upto = Math.min(1, phase / REVEAL_END);
const n = Math.floor(upto * steps);
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 관찰축 회전 — 사선 성분 tilt 를 진동시킨다
const tilt = Math.sin(tSec / SPIN_L * 6.2832) * 0.5;
const mid = W / 2;
ctx.strokeStyle = rgba(CA, 0.14);
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(mid, 0); ctx.lineTo(mid, H); ctx.stroke();
drawPane(az.x, az.y, az.z, mid * 0.52, H * 0.52, Math.min(W / 46, H / 42) * 0.6, CA, HEAD, n, fade, tilt, ctx);
drawPane(th.x, th.y, th.z, mid * 1.48, H * 0.5, Math.min(W / 46, H / 42) * 0.5, CT, HEAD, n, fade, tilt, ctx);
ctx.textAlign = "center";
ctx.fillStyle = rgba(CA, 0.9 * fade + 0.1);
ctx.fillText("AIZAWA", mid * 0.52, H - 6);
ctx.fillStyle = rgba(CT, 0.9 * fade + 0.1);
ctx.fillText("THOMAS", mid * 1.48, H - 6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const tSec = (now - t0) / 1000;
draw((tSec % spinS) / spinS, tSec);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
caRef.current = tokRGB(root, "--lz-ca"); // 아이자와 — 라일락 화이트
ctRef.current = tokRGB(root, "--lz-ct"); // 토머스 — 민트 크림
headRef.current = tokRGB(root, "--lz-head");// 머리 — 크림(토큰)
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
precompute(steps);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.62, 0.4);
}
window.addEventListener("resize", onResize);
if (reduced()) {
draw(0.62, 0.4);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, spinS]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}05 · アイザワ・トーマス — 球面と螺旋、別の方程式がくれた別の彫刻 — 計測 累積面積 0.768% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
dAizawa と dThomas — 形を決める6+1個の係数
アイザワは a=0.95, b=0.7, c=0.6, d=3.5, e=0.25, f=0.1 の6係数、トーマスは b=0.19 の1係数です。rk4 は f を差し替えられる引数付きで、03のものと同じ4回の傾き平均をどちらの系にも適用します。
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
];
}
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])
];
}
出発点を選んでから記録する
アイザワは球の中心近く (0.1, 0, 0)、トーマスは (1.1, 1.2, −0.6) から出発し、100歩の過渡を捨ててから1,800歩を記録します。始点が悪いと軌道が薄く広がって彫刻の輪郭が読めません。
06密度の雲 — 訪問回数をピクセル輝度として積む霧の質感
1点ではなく数千点を同時に放ち、各ピクセルの通過回数を足すと、軌道の線が霧のような密度場に変わります。同じ式を統計として読むヒストグラム描画です。シード固定乱数でアトラクター中心付近に撒いた2,000点をオイラー dt 0.016・フレームごとに2歩ずつ進め、xz 投影の訪問回数を150px のヒストグラムに蓄積して、2秒周期でリセットしてまた積みます。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>1点ではなく数千点を同時に放ち、ピクセルの通過回数を明るさとして足すと、軌道が霧のような密度場になります</span>
</p>
<div class="lz__field" role="application" aria-label="密度の雲の実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">同時2,000点 · オイラー dt 0.016</span>
<span class="lz__meta lz__meta--dim">canvas · ヒストグラム · 周期2秒</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 밀도 구름(06) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(2,000점 동시 오일러 적분 → 히스토그램 누적 → 감마 램프)가 만든다.
// 무대는 편 공통 심해 남색. 밀도 낮은 쪽=얼음, 높은 쪽=복숭아 파스텔.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-cl: #cfe3ff; // 낮은 밀도 — 얼음 파스텔
$lz-ch: #ffd9c9; // 높은 밀도 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-cl;
.lz {
--lz-panel: #{$lz-panel};
--lz-cl: #{$lz-cl};
--lz-ch: #{$lz-ch};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-cl; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-cl: #cfe3ff;
--lz-ch: #ffd9c9;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 밀도 구름(06) — 로렌츠 세 식을 통계로 읽는다.
// 2,000개 점을 아주 작은 섭동(±0.01)을 준 같은 시작 영역에서 동시에 적분하고
// 저해상도 히스토그램 버퍼에 방문 횟수를 쌓는다. 매 프레임 버퍼를 감마 램프로
// 뿌리면 날개 내부 구조가 은하처럼 밝아지는 밀도 장이 된다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NPTS = 2000; // 동시 점 수 — 8,000 으로 올리면 안개가 짙어진다(변형 3)
var DT = 0.016; // 오일러 걸음 — 프레임당 2회 적분
var CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var CL = tokRGB('--lz-cl'); // 밀도 낮은 쪽 — 얼음 파스텔
var CH2 = tokRGB('--lz-ch'); // 밀도 높은 쪽 — 복숭아 파스텔
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
var RES = 150; // 히스토그램 해상도(정사각)
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var hist = new Float32Array(RES * RES);
var px = new Float32Array(NPTS), py = new Float32Array(NPTS), pz = new Float32Array(NPTS);
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
}
// 결정론 난수 — 리셋마다 같은 시작 뭉치가 되게 하는 시드 고정
function rng(seed) {
var t = seed;
return function () {
t += 0x6D2B79F5;
var r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
var rand = rng(42);
function seedPoints() {
var i;
for (i = 0; i < NPTS; i++) {
px[i] = 0.6 + (rand() - 0.5) * 0.02;
py[i] = 0.7 + (rand() - 0.5) * 0.02;
pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
// 오일러 적분 — 프레임당 2걸음씩 진행
function stepAll() {
var i, k, dx, dy, dz;
for (k = 0; k < 2; k++) {
for (i = 0; i < NPTS; i++) {
var x = px[i], y = py[i], z = pz[i];
dx = SIG * (y - x); dy = x * (RHO - z) - y; dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
// 히스토그램 버퍼에 방문 누적 — xz 투영
function accumulate() {
var i, gx, gy;
for (i = 0; i < NPTS; i++) {
gx = (px[i] / 22 + 0.5) * (RES - 1);
gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
// 버퍼 → 화면 — 감마 램프로 낮은 밀도도 살리고 최대 밀도를 복숭아로
function blit(fade) {
var maxA = 0, i;
for (i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
var ctx2 = cvEl.getContext('2d');
ctx2.fillStyle = 'rgb(' + PANEL[0] + ',' + PANEL[1] + ',' + PANEL[2] + ')';
ctx2.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.94;
var ox = (W - side) / 2, oy = (H - side) / 2;
var cell = side / RES;
ctx2.save();
ctx2.translate(ox, oy);
var j, k2, v, g, col, alpha;
for (j = 0; j < RES; j++) {
for (k2 = 0; k2 < RES; k2++) {
v = hist[j * RES + k2];
if (v <= 0) continue;
g = Math.pow(v / maxA, 0.38);
col = mix(CL, CH2, g);
alpha = Math.min(1, 0.10 + g * 0.85) * fade;
ctx2.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + alpha + ')';
ctx2.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx2.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
if (phase < 0.02 && hist[0] + hist[RES * RES - 1] >= 0 && (now - t0) > 60 && phaseResetDone === false) {
hist = new Float32Array(RES * RES);
seedPoints();
phaseResetDone = true;
}
if (phase > 0.05) phaseResetDone = false;
stepAll();
accumulate();
var fade = phase > 0.9 ? Math.max(0, 1 - (phase - 0.9) / 0.1) : 1;
blit(fade);
requestAnimationFrame(frame);
}
var phaseResetDone = false;
window.addEventListener('resize', function () {
fit();
if (reduce) blit(1);
});
fit();
seedPoints();
if (reduce) {
var i;
for (i = 0; i < 90; i++) { stepAll(); accumulate(); } // reduce — 쌓인 한 장
blit(1);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzVoxelDensityCloud — vanilla 판(run/324/demo/06-lorenz-voxel-density-cloud/index.html) 의 React 포트.
// 2,000개 점을 아주 작은 섭동(±0.01)을 준 같은 시작 영역에서 동시에 적분하고
// 저해상도 히스토그램 버퍼에 방문 횟수를 쌓는다. 매 프레임 버퍼를 감마 램프로
// 뿌리면 날개 내부 구조가 은하처럼 밝아지는 밀도 장이 된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzVoxelDensityCloud.module.scss";
export type LorenzVoxelDensityCloudProps = {
duration?: string;
easing?: string;
color?: string;
npts?: number; // 동시 점 수 — 8,000 으로 올리면 안개가 짙어진다(변형 3)
dt?: number; // 오일러 걸음 — 프레임당 2회 적분
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NPTS = 2000; // 동시 점 수
const DT = 0.016; // 오일러 걸음
const CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
const RES = 150; // 히스토그램 해상도(정사각)
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "밀도 구름 실험판";
const META_LEFT = "동시 점 2,000 · 오일러 dt 0.016";
const META_RIGHT = "canvas · 히스토그램 · 주기 2초";
const HINT = "한 점 대신 수천 점을 동시에 풀어 픽셀의 통과 횟수를 밝기로 더하면 궤적이 안개 같은 밀도 장이 됩니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
// 결정론 난수 — 리셋마다 같은 시작 뭉치가 되게 하는 시드 고정
function rng(seed: number): () => number {
let t = seed;
return function (): number {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
export default function LorenzVoxelDensityCloud({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
npts = NPTS,
dt = DT
}: LorenzVoxelDensityCloudProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const clRef = useRef<RGB | null>(null); // 밀도 낮은 쪽 — 얼음 파스텔
const chRef = useRef<RGB | null>(null); // 밀도 높은 쪽 — 복숭아 파스텔
const panelRef = useRef<RGB | null>(null);
const histRef = useRef<Float32Array | null>(null);
const ptsRef = useRef<{ px: Float32Array; py: Float32Array; pz: Float32Array } | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function allocPoints(n: number): void {
ptsRef.current = {
px: new Float32Array(n),
py: new Float32Array(n),
pz: new Float32Array(n)
};
}
function seedPoints(): void {
const pts = ptsRef.current;
if (!pts) return;
const rand = rng(42);
for (let i = 0; i < pts.px.length; i++) {
pts.px[i] = 0.6 + (rand() - 0.5) * 0.02;
pts.py[i] = 0.7 + (rand() - 0.5) * 0.02;
pts.pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
// 오일러 적분 — 프레임당 2걸음씩 진행
function stepAll(step: number): void {
const pts = ptsRef.current;
if (!pts) return;
const { px, py, pz } = pts;
const N = px.length;
for (let k = 0; k < 2; k++) {
for (let i = 0; i < N; i++) {
const x = px[i], y = py[i], z = pz[i];
const dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
px[i] = x + dx * step; py[i] = y + dy * step; pz[i] = z + dz * step;
}
}
}
// 히스토그램 버퍼에 방문 누적 — xz 투영
function accumulate(): void {
const pts = ptsRef.current;
const hist = histRef.current;
if (!pts || !hist) return;
const { px, py, pz } = pts;
for (let i = 0; i < px.length; i++) {
const gx = (px[i] / 22 + 0.5) * (RES - 1);
const gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
// 버퍼 → 화면 — 감마 램프로 낮은 밀도도 살리고 최대 밀도를 복숭아로
function blit(fade: number): void {
const ctx = ctxRef.current;
const hist = histRef.current;
const CL = clRef.current;
const CH2 = chRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !hist || !CL || !CH2 || !PANEL) return;
let maxA = 0;
for (let i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
ctx.fillStyle = "rgb(" + PANEL[0] + "," + PANEL[1] + "," + PANEL[2] + ")";
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.94;
const ox = (W - side) / 2, oy = (H - side) / 2;
const cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
for (let j = 0; j < RES; j++) {
for (let k2 = 0; k2 < RES; k2++) {
const v = hist[j * RES + k2];
if (v <= 0) continue;
const g = Math.pow(v / maxA, 0.38);
const col = mix(CL, CH2, g);
const alpha = Math.min(1, 0.10 + g * 0.85) * fade;
ctx.fillStyle = "rgba(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
}
let t0 = -1;
let phaseResetDone = false;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
const hist = histRef.current;
if (phase < 0.02 && hist && hist[0] + hist[RES * RES - 1] >= 0 && (now - t0) > 60 && phaseResetDone === false) {
histRef.current = new Float32Array(RES * RES);
seedPoints();
phaseResetDone = true;
}
if (phase > 0.05) phaseResetDone = false;
stepAll(dt);
accumulate();
const fade = phase > 0.9 ? Math.max(0, 1 - (phase - 0.9) / 0.1) : 1;
blit(fade);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
clRef.current = tokRGB(root, "--lz-cl");
chRef.current = tokRGB(root, "--lz-ch");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
histRef.current = new Float32Array(RES * RES);
allocPoints(npts);
fit();
seedPoints();
function onResize(): void {
fit();
if (reduced()) blit(1);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let i = 0; i < 90; i++) { stepAll(dt); accumulate(); } // reduce — 쌓인 한 장
blit(1);
setStill(true);
} else {
t0 = -1;
phaseResetDone = false;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [npts, dt]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}06 · 密度の雲 — 訪問回数をピクセル輝度として積む霧の質感 — 計測 累積面積 1.04% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
seedPoints と stepAll — 2,000点の同時積分
点は (0.6, 0.7, 21) の周り ±0.01 に撒かれ、ごく近い出発点同士が時間とともに離れていく様子そのものがカオスの敏感性の可視化になっています。stepAll は毎フレーム2歩ずつ全点をオイラーで進めます。
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;
}
}
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 投影のヒストグラム
accumulate は各点の (x, z) を150px のマス目に落として +1 します。頻繁に通るマスは明るく育つので、軌道の密度がそのまま霧の濃さになり、1.04%という小さな累積面積は薄い霧を積むこの描画の正体です。
07ポアンカレ断面 — 軌道が平面を横切る瞬間だけを打つ
軌道が基準平面を横切る瞬間の座標だけに点を打つと、羽ごとに点の雲が帯になります。3次元の流れを2次元の地図へ折りたたむ古典的技法を画面に移した項目です。RK4 dt 0.005 で14,000歩(合計70時間単位)を密に積分し、前の z と今の z が基準面 z=27 をまたいだ瞬間を線形補間して、その (x, y) だけに打ちます。上昇通過はアイスブルー・下降通過はピンクに分かれます。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>軌道が基準平面を横切る瞬間の座標だけに点を打つと、羽ごとに点の雲が帯になります</span>
</p>
<div class="lz__field" role="application" aria-label="ポアンカレ断面の実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">z = 27 断面 · 双方向の通過を収集</span>
<span class="lz__meta lz__meta--dim">canvas · 断面地図 · 周期2秒</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 푸앵카레 단면(07) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(z=27 평면 통과 순간만 모아 점 구름을 쌓는다)가 만든다.
// 무대는 편 공통 심해 남색. 상승 통과=얼음, 하강 통과=복숭아 파스텔.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-cu: #cfe3ff; // 상승 통과 점 — 얼음 파스텔
$lz-cd: #ffd9c9; // 하강 통과 점 — 복숭아 파스텔
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-cu;
.lz {
--lz-panel: #{$lz-panel};
--lz-cu: #{$lz-cu};
--lz-cd: #{$lz-cd};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-cu; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-cu: #cfe3ff;
--lz-cd: #ffd9c9;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 푸앵카레 단면(07) — 3차원 궤적을 2차원 지도로 압축한다.
// RK4 로 긴 궤적을 사전 적분해 두고, z 가 기준값 27 을 건너는 순간만 선형 보간으로
// (x, y) 를 찍는다. 상승 통과는 얼음, 하강 통과는 복숭아 — 두 방향이 각 날개에 띠를 만든다.
// 점은 저해상도 히스토그램에 3x3 스플랏으로 누적해 감마 램프로 뿌린다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var DT = 0.005; // RK4 걸음 — 작게 잡아 통과 순간을 정밀하게 잡는다
var STEPS = 14000; // 사전 적분 걸음 수 (총 70 시간단위)
var CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var Z_PLANE = 27; // 기준 단면 — z = 27
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var CU = tokRGB('--lz-cu'); // 상승 통과 — 얼음 파스텔
var CD = tokRGB('--lz-cd'); // 하강 통과 — 복숭아 파스텔
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function deriv(x, y, z) {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rk4(p) {
var k1 = deriv(p[0], p[1], p[2]);
var k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
var k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
var k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
// 사전 적분 — 과도기 400걸음 버림
var xs = new Float32Array(STEPS), ys = new Float32Array(STEPS), zs = new Float32Array(STEPS);
var p = [0.6, 0.7, 21], i;
for (i = 0; i < 400; i++) p = rk4(p);
for (i = 0; i < STEPS; i++) {
xs[i] = p[0]; ys[i] = p[1]; zs[i] = p[2];
p = rk4(p);
}
// 통과 목록 — z 가 27 을 건너는 순간의 보간 (x, y) 와 방향(1 상승 / 0 하강)
var crX = [], crY = [], crUp = [];
for (i = 1; i < STEPS; i++) {
var za = zs[i - 1] - Z_PLANE, zb = zs[i] - Z_PLANE;
if ((za < 0 && zb >= 0) || (za >= 0 && zb < 0)) {
var t2 = za / (za - zb);
crX.push(xs[i - 1] + (xs[i] - xs[i - 1]) * t2);
crY.push(ys[i - 1] + (ys[i] - ys[i - 1]) * t2);
crUp.push(zb > za ? 1 : 0);
}
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var RES = 150; // 단면 지도 해상도(정사각)
var histU = new Float32Array(RES * RES);
var histD = new Float32Array(RES * RES);
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
}
// 통과 하나를 3x3 가우시안 근사 스플랏으로 누적
var W9 = [0.30, 0.55, 0.30, 0.55, 1.0, 0.55, 0.30, 0.55, 0.30];
function splat(cx2, cy2, up) {
var gx = (cx2 / 24 + 0.5) * (RES - 1);
var gy = (1 - (cy2 + 28) / 56) * (RES - 1);
var gx0 = Math.round(gx), gy0 = Math.round(gy), j, k2, w;
var dst = up ? histU : histD;
for (j = -1; j <= 1; j++) {
for (k2 = -1; k2 <= 1; k2++) {
var ix = gx0 + k2, iy = gy0 + j;
if (ix < 0 || ix >= RES || iy < 0 || iy >= RES) continue;
w = W9[(j + 1) * 3 + (k2 + 1)];
dst[iy * RES + ix] += w;
}
}
}
var REVEAL_END = 0.84;
function splatUpTo(count) {
var i2;
for (i2 = 0; i2 < count; i2++) splat(crX[i2], crY[i2], crUp[i2]);
}
function draw(phase) {
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var upto = Math.min(1, phase / REVEAL_END);
var fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 배경 — xy 투영 전체 궤적이 순차 공개되며 단면의 맥락을 깐다
var s = Math.min(W / 50, H / 58);
var n = Math.floor(upto * STEPS);
if (n > 2) {
ctx.strokeStyle = rgba(CU, 0.09 * fade);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W / 2 + xs[0] * s, H / 2 - ys[0] * s);
for (i = 1; i < n; i++) ctx.lineTo(W / 2 + xs[i] * s, H / 2 - ys[i] * s);
ctx.stroke();
}
// 단면 지도 — 두 방향 히스토그램을 감마 램프로 합성
var maxU = 0, maxD = 0, i3;
for (i3 = 0; i3 < histU.length; i3++) {
if (histU[i3] > maxU) maxU = histU[i3];
if (histD[i3] > maxD) maxD = histD[i3];
}
if (maxU <= 0) maxU = 1;
if (maxD <= 0) maxD = 1;
var sideW = W * 0.44, sideH = H * 0.9;
var cellW = sideW / RES, cellH = sideH / RES;
ctx.save();
ctx.translate((W - sideW) / 2, (H - sideH) / 2);
var j, k2, su, sd, tot, col, alpha;
for (j = 0; j < RES; j++) {
for (k2 = 0; k2 < RES; k2++) {
su = Math.pow(histU[j * RES + k2] / maxU, 0.34);
sd = Math.pow(histD[j * RES + k2] / maxD, 0.34);
tot = su + sd;
if (tot <= 0.003) continue;
col = mix(CU, CD, sd / tot);
alpha = Math.min(1, 0.12 + tot * 0.85) * fade;
ctx.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + alpha + ')';
ctx.fillRect(k2 * cellW, j * cellH, cellW + 0.5, cellH + 0.5);
}
}
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, splatted = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
var want = Math.floor(Math.min(1, phase / REVEAL_END) * crX.length);
if (want !== splatted) { // 통과 수가 변할 때만 다시 스플랏
histU = new Float32Array(RES * RES);
histD = new Float32Array(RES * RES);
splatUpTo(want);
splatted = want;
}
draw(phase);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.8);
});
fit();
if (reduce) {
splatUpTo(crX.length); // reduce — 다 찬 단면 지도 한 장
draw(0.8);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzPoincareSectionSlice — vanilla 판(run/324/demo/07-lorenz-poincare-section-slice/index.html) 의 React 포트.
// RK4 로 긴 궤적을 사전 적분해 두고, z 가 기준값 27 을 건너는 순간만 선형 보간으로
// (x, y) 를 찍는다. 상승 통과는 얼음, 하강 통과는 복숭아 — 두 방향이 각 날개에 띠를 만든다.
// 점은 저해상도 히스토그램에 3x3 스플랏으로 누적해 감마 램프로 뿌린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzPoincareSectionSlice.module.scss";
export type LorenzPoincareSectionSliceProps = {
duration?: string;
easing?: string;
color?: string;
dt?: number; // RK4 걸음 — 작게 잡아 통과 순간을 정밀하게 잡는다
steps?: number; // 사전 적분 걸음 수 (총 70 시간단위)
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const DT = 0.005; // RK4 걸음
const STEPS = 14000; // 사전 적분 걸음 수 (총 70 시간단위)
const CYCLE = 2.0; // 리셋 주기(초) — 설정 초(2s)와 같다
const RES = 150; // 단면 지도 해상도(정사각)
const REVEAL_END = 0.84;
const Z_PLANE = 27; // 기준 단면 — z = 27
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Vec3 = [number, number, number];
const FIELD_LABEL = "푸앵카레 단면 실험판";
const META_LEFT = "z = 27 단면 · 양방향 통과 수집";
const META_RIGHT = "canvas · 단면 지도 · 주기 2초";
const HINT = "궤적이 기준 평면을 통과하는 순간의 좌표에만 점을 찍으면 날개마다 점 구름이 띠를 이룹니다";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function deriv(x: number, y: number, z: number): Vec3 {
return [SIG * (y - x), x * (RHO - z) - y, x * y - BETA * z];
}
function rk4(p: Vec3, DT: number): Vec3 {
const k1 = deriv(p[0], p[1], p[2]);
const k2 = deriv(p[0] + k1[0] * DT / 2, p[1] + k1[1] * DT / 2, p[2] + k1[2] * DT / 2);
const k3 = deriv(p[0] + k2[0] * DT / 2, p[1] + k2[1] * DT / 2, p[2] + k2[2] * DT / 2);
const k4 = deriv(p[0] + k3[0] * DT, p[1] + k3[1] * DT, p[2] + k3[2] * DT);
return [
p[0] + DT / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0]),
p[1] + DT / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1]),
p[2] + DT / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
];
}
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
// 통과 하나를 3x3 가우시안 근사 스플랏으로 누적
const W9 = [0.30, 0.55, 0.30, 0.55, 1.0, 0.55, 0.30, 0.55, 0.30];
export default function LorenzPoincareSectionSlice({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
dt = DT,
steps = STEPS
}: LorenzPoincareSectionSliceProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const cuRef = useRef<RGB | null>(null); // 상승 통과 — 얼음 파스텔
const cdRef = useRef<RGB | null>(null); // 하강 통과 — 복숭아 파스텔
const panelRef = useRef<RGB | null>(null);
const trajRef = useRef<{ xs: Float32Array; ys: Float32Array; zs: Float32Array } | null>(null);
const crossRef = useRef<{ crX: number[]; crY: number[]; crUp: number[] } | null>(null);
const histURef = useRef<Float32Array | null>(null);
const histDRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
}
// 사전 적분 + 통과 목록 — 과도기 400걸음 버림
function precompute(n: number, step: number): void {
const xs = new Float32Array(n), ys = new Float32Array(n), zs = new Float32Array(n);
let p: Vec3 = [0.6, 0.7, 21];
for (let i = 0; i < 400; i++) p = rk4(p, step);
for (let i = 0; i < n; i++) {
xs[i] = p[0]; ys[i] = p[1]; zs[i] = p[2];
p = rk4(p, step);
}
// 통과 목록 — z 가 27 을 건너는 순간의 보간 (x, y) 와 방향(1 상승 / 0 하강)
const crX: number[] = [], crY: number[] = [], crUp: number[] = [];
for (let i = 1; i < n; i++) {
const za = zs[i - 1] - Z_PLANE, zb = zs[i] - Z_PLANE;
if ((za < 0 && zb >= 0) || (za >= 0 && zb < 0)) {
const t2 = za / (za - zb);
crX.push(xs[i - 1] + (xs[i] - xs[i - 1]) * t2);
crY.push(ys[i - 1] + (ys[i] - ys[i - 1]) * t2);
crUp.push(zb > za ? 1 : 0);
}
}
trajRef.current = { xs, ys, zs };
crossRef.current = { crX, crY, crUp };
}
// 통과 하나를 3x3 가우시안 근사 스플랏으로 누적
function splat(cx2: number, cy2: number, up: number): void {
const histU = histURef.current;
const histD = histDRef.current;
if (!histU || !histD) return;
const gx = (cx2 / 24 + 0.5) * (RES - 1);
const gy = (1 - (cy2 + 28) / 56) * (RES - 1);
const gx0 = Math.round(gx), gy0 = Math.round(gy);
const dst = up ? histU : histD;
for (let j = -1; j <= 1; j++) {
for (let k2 = -1; k2 <= 1; k2++) {
const ix = gx0 + k2, iy = gy0 + j;
if (ix < 0 || ix >= RES || iy < 0 || iy >= RES) continue;
const w = W9[(j + 1) * 3 + (k2 + 1)];
dst[iy * RES + ix] += w;
}
}
}
function splatUpTo(count: number): void {
const cross = crossRef.current;
if (!cross) return;
for (let i = 0; i < count; i++) splat(cross.crX[i], cross.crY[i], cross.crUp[i]);
}
function draw(phase: number): void {
const ctx = ctxRef.current;
const traj = trajRef.current;
const histU = histURef.current;
const histD = histDRef.current;
const PANEL = panelRef.current;
const CU = cuRef.current;
const CD = cdRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !traj || !histU || !histD || !PANEL || !CU || !CD) return;
const { xs, ys } = traj;
const STEPSN = xs.length;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const upto = Math.min(1, phase / REVEAL_END);
const fade = phase > REVEAL_END ? Math.max(0, 1 - (phase - REVEAL_END) / (1 - REVEAL_END)) : 1;
// 배경 — xy 투영 전체 궤적이 순차 공개되며 단면의 맥락을 깐다
const s = Math.min(W / 50, H / 58);
const n = Math.floor(upto * STEPSN);
if (n > 2) {
ctx.strokeStyle = rgba(CU, 0.09 * fade);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W / 2 + xs[0] * s, H / 2 - ys[0] * s);
for (let i = 1; i < n; i++) ctx.lineTo(W / 2 + xs[i] * s, H / 2 - ys[i] * s);
ctx.stroke();
}
// 단면 지도 — 두 방향 히스토그램을 감마 램프로 합성
let maxU = 0, maxD = 0;
for (let i = 0; i < histU.length; i++) {
if (histU[i] > maxU) maxU = histU[i];
if (histD[i] > maxD) maxD = histD[i];
}
if (maxU <= 0) maxU = 1;
if (maxD <= 0) maxD = 1;
const sideW = W * 0.44, sideH = H * 0.9;
const cellW = sideW / RES, cellH = sideH / RES;
ctx.save();
ctx.translate((W - sideW) / 2, (H - sideH) / 2);
for (let j = 0; j < RES; j++) {
for (let k2 = 0; k2 < RES; k2++) {
const su = Math.pow(histU[j * RES + k2] / maxU, 0.34);
const sd = Math.pow(histD[j * RES + k2] / maxD, 0.34);
const tot = su + sd;
if (tot <= 0.003) continue;
const col = mix(CU, CD, sd / tot);
const alpha = Math.min(1, 0.12 + tot * 0.85) * fade;
ctx.fillStyle = "rgba(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cellW, j * cellH, cellW + 0.5, cellH + 0.5);
}
}
ctx.restore();
}
let t0 = -1;
let splatted = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const cross = crossRef.current;
if (!cross) return;
const phase = (((now - t0) / 1000) % CYCLE) / CYCLE;
const want = Math.floor(Math.min(1, phase / REVEAL_END) * cross.crX.length);
if (want !== splatted) { // 통과 수가 변할 때만 다시 스플랏
histURef.current = new Float32Array(RES * RES);
histDRef.current = new Float32Array(RES * RES);
splatUpTo(want);
splatted = want;
}
draw(phase);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
cuRef.current = tokRGB(root, "--lz-cu");
cdRef.current = tokRGB(root, "--lz-cd");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
histURef.current = new Float32Array(RES * RES);
histDRef.current = new Float32Array(RES * RES);
precompute(steps, dt);
fit();
function onResize(): void {
fit();
if (reduced()) draw(0.8);
}
window.addEventListener("resize", onResize);
if (reduced()) {
splatUpTo(crossRef.current ? crossRef.current.crX.length : 0); // reduce — 다 찬 단면 지도 한 장
draw(0.8);
setStill(true);
} else {
t0 = -1;
splatted = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps, dt]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}07 · ポアンカレ断面 — 軌道が平面を横切る瞬間だけを打つ — 計測 累積面積 3.425% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
通過検出と線形補間
za・zb が異符号のとき、t2 = za / (za − zb) でまたぎの位置を補間します。この1行がないと点がまたぎの前後の離れた2箇所に割れて、地図の精度が失われます。
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);
}
}
3×3のガウス近似スプラット
打った点は1マスではなく、重み 0.30〜1.0 の3×3のスプラットで150px の地図に散らしています。疎な通過点でも帯が連続して見えるのはこの柔らかさのためで、3.425%という面積は点の雲の地図として自然な大きさです。
08パラメータのつまみ — ポインタでσ・ρをひねると形が変わる
3つの係数をポインタ位置に結ぶと、蝶が潰れたり裏返ったり新しい軌道に生まれ変わります。カオス研究の実際である係数探検をインタラクションにした操作レイヤーです。ポインタ x で σ(4〜24)・y で ρ(10〜45) を決め、アトラクターの両羽の軸を囲む円周12分割のシード軌道を毎フレーム積分し直します。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>3つの係数をポインタ位置に結ぶと、蝶が潰れたり裏返ったり新しい軌道に生まれ変わります</span>
</p>
<div class="lz__field" role="application" aria-label="パラメータつまみの実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">ポインタxでσ · yでρ · 12軌道</span>
<span class="lz__meta lz__meta--dim">canvas · 係数反映 · 60ms遅延</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 파라미터 손잡이(08) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(포인터 위치를 σ·ρ 로 읽어 12개 궤적을 다시 적분)가 만든다.
// 무대는 편 공통 심해 남색. 궤적 12색은 얼음→라일락→복숭아 파스텔 그라데이션.
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-c0: #cfe3ff; // 궤적 시작 색 — 얼음 파스텔
$lz-c1: #e6d9ff; // 궤적 중간 색 — 라일락 파스텔
$lz-c2: #ffd9c9; // 궤적 끝 색 — 복숭아 파스텔
$lz-head: $subject-cream; // 머리 점 — 크림(토큰)
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-c2: #{$lz-c2};
--lz-head: #{$lz-head};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
cursor: crosshair;
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #e6d9ff;
--lz-c2: #ffd9c9;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
cursor: crosshair;
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 파라미터 손잡이(08) — 계수를 손으로 만지는 실험판.
// 포인터 가로 위치를 σ(4~24), 세로 위치를 ρ(10~45)로 읽고, 현재 계수는 τ 0.06(=60ms)마다
// 목표를 향해 다가간다. 계수가 바뀌면 12개 시드의 궤적을 매 프레임 다시 적분해 보여준다.
// (σ, ρ) 평면을 훑으면 카오스 영역과 고정점으로 빠지는 영역의 경계가 손에 잡힌다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var ORBITS = 12; // 동시 궤적 수 — 시드를 원주 12분할에 뿌린다
var STEPS = 900; // 궤적당 걸음 수
var DT = 0.012; // 오일러 걸음
var TAU = 0.06; // 계수 추종 시정수(초) — 설정 60ms 지연과 같다
// ────────────────────────────────────────────────────
var BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C0 = tokRGB('--lz-c0'); // 궤적 시작 — 얼음
var C1 = tokRGB('--lz-c1'); // 궤적 중간 — 라일락
var C2 = tokRGB('--lz-c2'); // 궤적 끝 — 복숭아
var HEAD = tokRGB('--lz-head'); // 머리 — 크림(토큰)
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
function orbitColor(k) {
var t = k / (ORBITS - 1);
return t < 0.5 ? mix(C0, C1, t * 2) : mix(C1, C2, (t - 0.5) * 2);
}
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 계수 상태 — 현재값이 목표를 τ 60ms 로 쫓는다
var sig = 10, rho = 28;
var sigT = 10, rhoT = 28;
var hasPtr = false;
// 시드 — 어트랙터 중심 두 날개 축을 원주 12분할로 감싼다
var seeds = [];
var k;
for (k = 0; k < ORBITS; k++) {
var ang = k / ORBITS * 6.2832;
seeds.push([8.5 + Math.cos(ang) * 6.5, 8.5 + Math.sin(ang) * 6.5, 26 + Math.cos(ang * 2) * 3]);
}
// 계수에 맞춰 12궤적을 다시 적분 — 계수가 살아 있으므로 매 프레임 재계산한다
var bufX = new Float32Array(STEPS), bufZ = new Float32Array(STEPS);
function traceOrbit(sx, sy, sz, sigv, rhov) {
var x = sx, y = sy, z = sz, i;
for (i = 0; i < 60; i++) { // 과도기
var dx0 = sigv * (y - x), dy0 = x * (rhov - z) - y, dz0 = x * y - BETA * z;
x += dx0 * DT; y += dy0 * DT; z += dz0 * DT;
}
for (i = 0; i < STEPS; i++) {
bufX[i] = x; bufZ[i] = z;
var dx = sigv * (y - x), dy = x * (rhov - z) - y, dz = x * y - BETA * z;
x += dx * DT; y += dy * DT; z += dz * DT;
if (x > 900 || x < -900 || z > 900 || z < -900) { // 발산 가드
for (var i2 = i + 1; i2 < STEPS; i2++) { bufX[i2] = x; bufZ[i2] = z; }
return i;
}
}
return STEPS;
}
var lastSig = -1, lastRho = -1, lastT = 0;
function draw(dt, tSec) {
// τ 추종 — 목표 계수에 60ms 시정수로 다가간다
var f = 1 - Math.exp(-dt / TAU);
sig += (sigT - sig) * f;
rho += (rhoT - rho) * f;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
// σ·ρ 변화가 임계를 넘을 때만 재적분 — 매 프레임 재계산 낭비를 막는다
var need = Math.abs(sig - lastSig) > 0.05 || Math.abs(rho - lastRho) > 0.1;
var s = Math.min(W / 48, H / 54);
var ox = W / 2, oy = H * 0.52;
var k2, i3, n, col, hx, hy;
for (k2 = 0; k2 < ORBITS; k2++) {
if (need || orbits[k2] === null) {
n = traceOrbit(seeds[k2][0], seeds[k2][1], seeds[k2][2], sig, rho);
orbits[k2] = [Array.prototype.slice.call(bufX, 0, n), Array.prototype.slice.call(bufZ, 0, n)];
}
var orbx = orbits[k2][0], orbz = orbits[k2][1];
var L = orbx.length;
if (L < 8) continue;
col = orbitColor(k2);
// 혜성 꼬리 — 궤도 위를 순회하는 밝은 꼬리가 항상 움직임을 만든다
var tip = Math.floor(((tSec * 0.45 + k2 / ORBITS) % 1) * (L - 1));
var from = Math.max(0, tip - 150);
// 뒤네루게 — 궤도 전체를 옅게 깔아 형태를 보여준다
ctx.strokeStyle = rgba(col, 0.10);
ctx.lineWidth = 3.0;
ctx.beginPath();
ctx.moveTo(ox + orbx[0] * s, oy - (orbz[0] - 25) * s);
for (i3 = 1; i3 < L; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
// 알맹이 꼬리 — 진행 방향으로 갈수록 밝아진다
ctx.lineWidth = 1.3;
var seg = 6;
for (var q = 0; q < seg; q++) {
var q0 = from + Math.floor((tip - from) * q / seg);
var q1 = from + Math.floor((tip - from) * (q + 1) / seg);
if (q1 <= q0) continue;
ctx.strokeStyle = rgba(col, 0.14 + 0.62 * (q + 1) / seg);
ctx.beginPath();
ctx.moveTo(ox + orbx[q0] * s, oy - (orbz[q0] - 25) * s);
for (i3 = q0 + 1; i3 <= q1; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
}
// 머리
hx = ox + orbx[tip] * s; hy = oy - (orbz[tip] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.20);
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 0.9);
ctx.beginPath(); ctx.arc(hx, hy, 1.8, 0, 6.2832); ctx.fill();
}
lastSig = sig; lastRho = rho;
// 계수 읽기 — 현재 σ·ρ 표시
ctx.textAlign = 'left';
ctx.fillStyle = rgba(C0, 0.85);
ctx.fillText('σ ' + sig.toFixed(1), 8, H - 8);
ctx.textAlign = 'right';
ctx.fillStyle = rgba(C2, 0.85);
ctx.fillText('ρ ' + rho.toFixed(1), W - 8, H - 8);
if (!hasPtr) {
ctx.textAlign = 'center';
ctx.fillStyle = rgba(HEAD, 0.5);
ctx.fillText('ポインターを乗せて σ・ρ を動かしてみましょう', W / 2, H - 8);
}
}
var orbits = [];
for (k = 0; k < ORBITS; k++) orbits.push(null);
// 포인터 입력 — field 좌표를 계수 목표로
function setTarget(ev) {
var r = fieldEl.getBoundingClientRect();
var px = (ev.clientX - r.left) / r.width;
var py = (ev.clientY - r.top) / r.height;
px = Math.max(0, Math.min(1, px));
py = Math.max(0, Math.min(1, py));
sigT = 4 + px * 20; // σ 4~24
rhoT = 45 - py * 35; // ρ 10~45 (위가 높은 ρ)
hasPtr = true;
}
fieldEl.addEventListener('pointermove', setTarget);
fieldEl.addEventListener('pointerdown', setTarget);
fieldEl.addEventListener('pointerleave', function () {
sigT = 10; rhoT = 28; // 손을 떼면 고전 계수로 돌아간다
hasPtr = false;
});
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1;
function frame(now) {
if (t0 < 0) t0 = now;
var dt = Math.min(0.05, (now - lastT) / 1000 || 0.016);
lastT = now;
draw(dt, (now - t0) / 1000);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) draw(0.016, 0.5);
});
fit();
if (reduce) {
// reduce — 고전 계수 한 장. 재적분 1회 + 안내 문구.
draw(0.016, 0.5);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzPointerParameterKnob — vanilla 판(run/324/demo/08-lorenz-pointer-parameter-knob/index.html) 의 React 포트.
// 포인터 가로 위치를 σ(4~24), 세로 위치를 ρ(10~45)로 읽고, 현재 계수는 τ 0.06(=60ms)마다
// 목표를 향해 다가간다. 계수가 바뀌면 12개 시드의 궤적을 매 프레임 다시 적분해 보여준다.
// (σ, ρ) 평면을 훑으면 카오스 영역과 고정점으로 빠지는 영역의 경계가 손에 잡힌다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzPointerParameterKnob.module.scss";
export type LorenzPointerParameterKnobProps = {
duration?: string;
easing?: string;
color?: string;
orbits?: number; // 동시 궤적 수 — 시드를 원주 12분할에 뿌린다
steps?: number; // 궤적당 걸음 수
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const ORBITS = 12; // 동시 궤적 수
const STEPS = 900; // 궤적당 걸음 수
const DT = 0.012; // 오일러 걸음
const TAU = 0.06; // 계수 추종 시정수(초) — 설정 60ms 지연과 같다
const BETA = 8 / 3;
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Orbit = { x: number[]; z: number[] } | null;
const FIELD_LABEL = "파라미터 손잡이 실험판";
const META_LEFT = "포인터 x 로 σ · y 로 ρ · 12분할 궤적";
const META_RIGHT = "canvas · 계수 반영 · 60ms 지연";
const HINT = "세 계수를 포인터 위치에 묶으면 나비가 찌그러지고 뒤집히며 새로운 궤도로 태어납니다";
const GUIDE = "포인터를 올려 σ · ρ 를 움직여 보세요";
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
export default function LorenzPointerParameterKnob({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
orbits = ORBITS,
steps = STEPS
}: LorenzPointerParameterKnobProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const c0Ref = useRef<RGB | null>(null); // 궤적 시작 — 얼음
const c1Ref = useRef<RGB | null>(null); // 궤적 중간 — 라일락
const c2Ref = useRef<RGB | null>(null); // 궤적 끝 — 복숭아
const headRef = useRef<RGB | null>(null); // 머리 — 크림(토큰)
const panelRef = useRef<RGB | null>(null);
const orbitsRef = useRef<Orbit[]>([]);
const bufXRef = useRef<Float32Array | null>(null);
const bufZRef = useRef<Float32Array | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
// 계수 상태 — 현재값이 목표를 τ 60ms 로 쫓는다
const sigRef = useRef(10) as Box<number>;
const rhoRef = useRef(28) as Box<number>;
const sigTRef = useRef(10) as Box<number>;
const rhoTRef = useRef(28) as Box<number>;
const hasPtrRef = useRef(false) as Box<boolean>;
const lastSigRef = useRef(-1) as Box<number>;
const lastRhoRef = useRef(-1) as Box<number>;
const lastTRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctxRef.current;
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.font = '600 10px -apple-system, "Segoe UI", sans-serif';
}
// 시드 — 어트랙터 중심 두 날개 축을 원주 12분할로 감싼다
function makeSeeds(n: number): [number, number, number][] {
const seeds: [number, number, number][] = [];
for (let k = 0; k < n; k++) {
const ang = k / n * 6.2832;
seeds.push([8.5 + Math.cos(ang) * 6.5, 8.5 + Math.sin(ang) * 6.5, 26 + Math.cos(ang * 2) * 3]);
}
return seeds;
}
// 계수에 맞춰 12궤적을 다시 적분 — 계수가 살아 있으므로 매 프레임 재계산한다
function traceOrbit(sx: number, sy: number, sz: number, sigv: number, rhov: number, stepsN: number): number {
const bufX = bufXRef.current;
const bufZ = bufZRef.current;
if (!bufX || !bufZ) return 0;
let x = sx, y = sy, z = sz;
for (let i = 0; i < 60; i++) { // 과도기
const dx0 = sigv * (y - x), dy0 = x * (rhov - z) - y, dz0 = x * y - BETA * z;
x += dx0 * DT; y += dy0 * DT; z += dz0 * DT;
}
for (let i = 0; i < stepsN; i++) {
bufX[i] = x; bufZ[i] = z;
const dx = sigv * (y - x), dy = x * (rhov - z) - y, dz = x * y - BETA * z;
x += dx * DT; y += dy * DT; z += dz * DT;
if (x > 900 || x < -900 || z > 900 || z < -900) { // 발산 가드
for (let i2 = i + 1; i2 < stepsN; i2++) { bufX[i2] = x; bufZ[i2] = z; }
return i;
}
}
return stepsN;
}
function draw(dt: number, tSec: number): void {
const ctx = ctxRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const C2 = c2Ref.current;
const HEAD = headRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !C0 || !C1 || !C2 || !HEAD || !PANEL) return;
// τ 추종 — 목표 계수에 60ms 시정수로 다가간다
const f = 1 - Math.exp(-dt / TAU);
sigRef.current += (sigTRef.current - sigRef.current) * f;
rhoRef.current += (rhoTRef.current - rhoRef.current) * f;
const sig = sigRef.current, rho = rhoRef.current;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
// σ·ρ 변화가 임계를 넘을 때만 재적분 — 매 프레임 재계산 낭비를 막는다
const need = Math.abs(sig - lastSigRef.current) > 0.05 || Math.abs(rho - lastRhoRef.current) > 0.1;
const s = Math.min(W / 48, H / 54);
const ox = W / 2, oy = H * 0.52;
const seeds = makeSeeds(orbits);
const orbs = orbitsRef.current;
if (!orbs) return;
for (let k2 = 0; k2 < orbits; k2++) {
if (need || orbs[k2] === null) {
const n = traceOrbit(seeds[k2][0], seeds[k2][1], seeds[k2][2], sig, rho, steps);
const bx = bufXRef.current, bz = bufZRef.current;
if (!bx || !bz) return;
orbs[k2] = {
x: Array.prototype.slice.call(bx, 0, n) as number[],
z: Array.prototype.slice.call(bz, 0, n) as number[]
};
}
const orb = orbs[k2];
if (!orb) continue;
const orbx = orb.x, orbz = orb.z;
const L = orbx.length;
if (L < 8) continue;
const t = k2 / (orbits - 1);
const col: RGB = t < 0.5 ? mix(C0, C1, t * 2) : mix(C1, C2, (t - 0.5) * 2);
// 혜성 꼬리 — 궤도 위를 순회하는 밝은 꼬리가 항상 움직임을 만든다
const tip = Math.floor(((tSec * 0.45 + k2 / orbits) % 1) * (L - 1));
const from = Math.max(0, tip - 150);
// 뒤네루게 — 궤도 전체를 옅게 깔아 형태를 보여준다
ctx.strokeStyle = rgba(col, 0.10);
ctx.lineWidth = 3.0;
ctx.beginPath();
ctx.moveTo(ox + orbx[0] * s, oy - (orbz[0] - 25) * s);
for (let i3 = 1; i3 < L; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
// 알맹이 꼬리 — 진행 방향으로 갈수록 밝아진다
ctx.lineWidth = 1.3;
const seg = 6;
for (let q = 0; q < seg; q++) {
const q0 = from + Math.floor((tip - from) * q / seg);
const q1 = from + Math.floor((tip - from) * (q + 1) / seg);
if (q1 <= q0) continue;
ctx.strokeStyle = rgba(col, 0.14 + 0.62 * (q + 1) / seg);
ctx.beginPath();
ctx.moveTo(ox + orbx[q0] * s, oy - (orbz[q0] - 25) * s);
for (let i3 = q0 + 1; i3 <= q1; i3++) ctx.lineTo(ox + orbx[i3] * s, oy - (orbz[i3] - 25) * s);
ctx.stroke();
}
// 머리
const hx = ox + orbx[tip] * s, hy = oy - (orbz[tip] - 25) * s;
ctx.fillStyle = rgba(HEAD, 0.20);
ctx.beginPath(); ctx.arc(hx, hy, 4.5, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 0.9);
ctx.beginPath(); ctx.arc(hx, hy, 1.8, 0, 6.2832); ctx.fill();
}
lastSigRef.current = sig;
lastRhoRef.current = rho;
// 계수 읽기 — 현재 σ·ρ 표시
ctx.textAlign = "left";
ctx.fillStyle = rgba(C0, 0.85);
ctx.fillText("σ " + sig.toFixed(1), 8, H - 8);
ctx.textAlign = "right";
ctx.fillStyle = rgba(C2, 0.85);
ctx.fillText("ρ " + rho.toFixed(1), W - 8, H - 8);
if (!hasPtrRef.current) {
ctx.textAlign = "center";
ctx.fillStyle = rgba(HEAD, 0.5);
ctx.fillText(GUIDE, W / 2, H - 8);
}
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const dt = Math.min(0.05, (now - lastTRef.current) / 1000 || 0.016);
lastTRef.current = now;
draw(dt, (now - t0) / 1000);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
const field = fieldRef.current;
if (!root || !cv || !field) return;
c0Ref.current = tokRGB(root, "--lz-c0");
c1Ref.current = tokRGB(root, "--lz-c1");
c2Ref.current = tokRGB(root, "--lz-c2");
headRef.current = tokRGB(root, "--lz-head");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
bufXRef.current = new Float32Array(steps);
bufZRef.current = new Float32Array(steps);
orbitsRef.current = new Array(orbits).fill(null);
fit();
// 포인터 입력 — field 좌표를 계수 목표로
function setTarget(ev: PointerEvent): void {
const fld = fieldRef.current;
if (!fld) return;
const r = fld.getBoundingClientRect();
let px = (ev.clientX - r.left) / r.width;
let py = (ev.clientY - r.top) / r.height;
px = Math.max(0, Math.min(1, px));
py = Math.max(0, Math.min(1, py));
sigTRef.current = 4 + px * 20; // σ 4~24
rhoTRef.current = 45 - py * 35; // ρ 10~45 (위가 높은 ρ)
hasPtrRef.current = true;
}
function onLeave(): void {
sigTRef.current = 10; rhoTRef.current = 28; // 손을 떼면 고전 계수로 돌아간다
hasPtrRef.current = false;
}
field.addEventListener("pointermove", setTarget);
field.addEventListener("pointerdown", setTarget);
field.addEventListener("pointerleave", onLeave);
function onResize(): void {
fit();
if (reduced()) draw(0.016, 0.5);
}
window.addEventListener("resize", onResize);
if (reduced()) {
// reduce — 고전 계수 한 장. 재적분 1회 + 안내 문구.
draw(0.016, 0.5);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
field.removeEventListener("pointermove", setTarget);
field.removeEventListener("pointerdown", setTarget);
field.removeEventListener("pointerleave", onLeave);
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orbits, steps]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}08 · パラメータのつまみ — ポインタでシグマ・ローをひねると形が変わる — 計測 累積面積 9.233% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
τ 60ms の追従遅れ
係数は即時反映ではなく τ 0.06秒の遅れで目標に近づきます。手を動かすたびに軌道が跳ねると操作感が損なわれるので、この追従遅れが「ひねると形が流れる」感触を作ります。
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;
// σ・ρの変化が閾値を超えたときだけ再積分 — 毎フレーム再計算の浪費を防ぐ
var need = Math.abs(sig - lastSig) > 0.05 || Math.abs(rho - lastRho) > 0.1;
for (var 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;
発散ガード — 軌道が画面外へ走るのを止める
係数を極端な領域へ持っていくと軌道は外へ発散します。traceOrbit は |x| か |z| が900を超えた時点で残り歩を同じ座標で埋めて打ち切り、12軌道のうち壊れたものだけが静かに沈むようにしています。
09蝶のヒーローシーン — 文字・粒子と重ねる表紙の場面
密度の雲を背景に敷き、ビネット・文字・ゆっくりした回転を重ねると、サイエンスチャンネルやデータサービスの表紙に使える場面が完成します。計算画面1つがサービスの最初の画面になる組立例です。2,400点の雲(オイラー dt 0.016)の上を、前もって積分した銀河の腕の明るい頭が12秒周期で流れ、観察角は30秒に1周回ります。3行の文字(CHAOS BUTTERFLY・ローレンツアトラクター シミュレーター・dz/dt = xy − βz)を重ねて表紙を締めます。

<main class="stage">
<div class="lz is-demo" id="lzRoot">
<p class="lz__hint">
<span>密度の雲を背景に敷き、ビネット・文字・ゆっくりした回転を重ねるとサイエンスサービスの表紙が完成します</span>
</p>
<div class="lz__field" role="application" aria-label="蝶のヒーローシーンの実験盤" tabindex="0">
<canvas class="lz__cv" id="lzCv"></canvas>
</div>
<div class="lz__bar">
<span class="lz__meta">銀河腕軌道 12秒 · 観察角 30秒</span>
<span class="lz__meta lz__meta--dim">canvas · 銀河の軌跡 · 常に動く</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 나비 히어로 씬(09) — @keyframes 를 두지 않는다.
// 움직임은 캔버스 rAF 루프(수천 점 밀도 구름 누적 + 30초 주기 느린 회전 + 12초 주기 궤적 헤드)가 만든다.
// 무대는 편 공통 심해 남색. 구름=얼음→복숭아, 글자=크림(토큰).
$lz-stage: #16202b;
@include stage($lz-stage);
$lz-panel: #0d1622;
$lz-text: #ccd8e6;
$lz-c0: #cfe3ff; // 밀도 낮은 쪽 — 얼음 파스텔
$lz-c1: #ffd9c9; // 밀도 높은 쪽 — 복숭아 파스텔
$lz-head: $subject-cream; // 글자·헤드 — 크림(토큰)
$duration: $dur-loop;
$easing: $ease-out;
$color: $lz-c0;
.lz {
--lz-panel: #{$lz-panel};
--lz-c0: #{$lz-c0};
--lz-c1: #{$lz-c1};
--lz-head: #{$lz-head};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.lz__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $lz-text;
}
.lz__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $lz-panel;
box-shadow: inset 0 0 0 1px rgba($lz-text, .18);
}
.lz__field:focus-visible { outline: 2px solid $lz-c0; outline-offset: 2px; }
.lz__cv { display: block; width: 100%; height: 100%; }
.lz__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.lz__meta { font-size: 10px; font-weight: 500; color: rgba($lz-text, .72); }
.lz__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.lz { gap: $sp-1; }
.lz__field { height: 100px; }
.lz__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #16202b;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lz {
--lz-panel: #0d1622;
--lz-c0: #cfe3ff;
--lz-c1: #ffd9c9;
--lz-head: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.lz__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #ccd8e6;
}
.lz__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0d1622;
box-shadow: inset 0 0 0 1px rgba(204, 216, 230, 0.18);
}
.lz__field:focus-visible {
outline: 2px solid #cfe3ff;
outline-offset: 2px;
}
.lz__cv {
display: block;
width: 100%;
height: 100%;
}
.lz__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.lz__meta {
font-size: 10px;
font-weight: 500;
color: rgba(204, 216, 230, 0.72);
}
.lz__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.lz {
gap: 4px;
}
.lz__field {
height: 100px;
}
.lz__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.lz.is-still .lz__field {
cursor: default !important;
}
}// 나비 히어로 씬(09) — 밀도 구름 위에 타이포를 얹는 대문 장면.
// 2,400점을 동시 적분해 xz 히스토그램 밀도 구름(감마 램프)을 쌓고,
// 관찰각을 30초 주기(SPIN_L)로 아주 느리게 돌린다. 밝은 궤적 한 줄이 구름 위를 12초 주기로
// 흐르며 은하 팔처럼 보이게 하고, 비네팅과 세 줄 글자로 서비스 대문을 완성한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var NPTS = 2400; // 동시 점 수 — 밀도 구름 결
var DT = 0.016; // 오일러 걸음 — 프레임당 2회
var SPIN_L = 30.0; // 관찰각 회전 주기(초) — 설정 30초와 같다
// ────────────────────────────────────────────────────
var SIG = 10, RHO = 28, BETA = 8 / 3;
var root = document.getElementById('lzRoot');
var cvEl = document.getElementById('lzCv');
if (!root || !cvEl) return;
var fieldEl = cvEl.parentElement;
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C0 = tokRGB('--lz-c0'); // 밀도 낮은 쪽 — 얼음
var C1 = tokRGB('--lz-c1'); // 밀도 높은 쪽 — 복숭아
var HEAD = tokRGB('--lz-head'); // 글자·헤드 — 크림(토큰)
var PANEL = tokRGB('--lz-panel');
function mix(a, b, t) { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; }
function rgba(c, a) { return 'rgba(' + (c[0] | 0) + ',' + (c[1] | 0) + ',' + (c[2] | 0) + ',' + a + ')'; }
var ctx = cvEl.getContext('2d');
var W = 0, H = 0;
var RES = 150; // 밀도 히스토그램 해상도
var hist = new Float32Array(RES * RES);
var px = new Float32Array(NPTS), py = new Float32Array(NPTS), pz = new Float32Array(NPTS);
function fit() {
var r = fieldEl.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
cvEl.width = W; cvEl.height = H;
}
function rng(seed) {
var t = seed;
return function () {
t += 0x6D2B79F5;
var r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
var rand = rng(99);
function seedPoints() {
var i;
for (i = 0; i < NPTS; i++) {
px[i] = 0.6 + (rand() - 0.5) * 0.02;
py[i] = 0.7 + (rand() - 0.5) * 0.02;
pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
function stepAll() {
var i, k, dx, dy, dz;
for (k = 0; k < 2; k++) {
for (i = 0; i < NPTS; i++) {
var x = px[i], y = py[i], z = pz[i];
dx = SIG * (y - x); dy = x * (RHO - z) - y; dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
// 회전 각도에 맞춰 xz 히스토그램에 누적 — 회전은 x/y 축 섞기로 표현
function accumulate(a) {
var ca = Math.cos(a), sa = Math.sin(a);
var i, u, v, gx, gy;
for (i = 0; i < NPTS; i++) {
u = px[i] * ca - py[i] * sa;
v = px[i] * sa + py[i] * ca;
gx = (u / 22 + 0.5) * (RES - 1);
gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
function blit(tSec) {
var maxA = 0, i;
for (i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
var side = Math.min(W, H) * 0.96;
var ox = (W - side) / 2, oy = (H - side) / 2;
var cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
var j, k2, v, g, col, alpha;
for (j = 0; j < RES; j++) {
for (k2 = 0; k2 < RES; k2++) {
v = hist[j * RES + k2];
if (v <= 0) continue;
g = Math.pow(v / maxA, 0.38);
col = mix(C0, C1, g);
alpha = Math.min(1, 0.08 + g * 0.80);
ctx.fillStyle = 'rgba(' + (col[0] | 0) + ',' + (col[1] | 0) + ',' + (col[2] | 0) + ',' + alpha + ')';
ctx.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
// 은하 팔 — 밝은 단일 궤적이 구름 위를 크리프한다
// (사전 적분 궤적에서 phase 로 읽는 게 아니라, tSec 에 τ 크리프를 태워 머리를 끌고 다닌다)
var armT = (tSec % 12) / 12;
var armN = 700;
var ai = Math.floor(armT * armN);
var s2 = Math.min(W, H) / 52;
var ca2 = Math.cos(tSec / SPIN_L * 6.2832), sa2 = Math.sin(tSec / SPIN_L * 6.2832);
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(HEAD, 0.5);
ctx.lineWidth = 2.2;
ctx.beginPath();
var a0 = Math.max(0, ai - 160), hx2, hy2, p2, u2, v2;
for (i = a0; i <= ai; i++) {
p2 = armXZ(i);
u2 = p2[0] * ca2 - p2[1] * sa2;
hx2 = ox + (u2 / 22 + 0.5) * side;
hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
if (i === a0) ctx.moveTo(hx2, hy2); else ctx.lineTo(hx2, hy2);
}
ctx.stroke();
// 팔 머리 — 크림 점
if (ai >= 0) {
p2 = armXZ(ai);
u2 = p2[0] * ca2 - p2[1] * sa2;
hx2 = ox + (u2 / 22 + 0.5) * side;
hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx2, hy2, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx2, hy2, 2.2, 0, 6.2832); ctx.fill();
}
// 비네팅 — 가장자리를 어둡게 눌러 글자가 뜨게 한다
var vg = ctx.createRadialGradient(W / 2, H * 0.46, Math.min(W, H) * 0.28, W / 2, H / 2, Math.max(W, H) * 0.75);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(4,9,16,0.62)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// 타이포 — 대문 세 줄
ctx.textAlign = 'center';
ctx.fillStyle = rgba(HEAD, 0.94);
ctx.font = '700 15px -apple-system, "Segoe UI", sans-serif';
ctx.fillText('CHAOS BUTTERFLY', W / 2, H * 0.40);
ctx.fillStyle = rgba(HEAD, 0.62);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText('ローレンツアトラクター シミュレーター', W / 2, H * 0.40 + 18);
ctx.fillStyle = rgba(HEAD, 0.40);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText('dz/dt = xy − βz', W / 2, H * 0.40 + 33);
}
// 은하 팔용 사전 적분 궤적 (x, y, z)
var armPts = [];
(function () {
var x = 0.6, y = 0.7, z = 21, i;
var adt = 0.012;
for (i = 0; i < 300; i++) {
var dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
x += dx * adt; y += dy * adt; z += dz * adt;
}
for (i = 0; i < 700; i++) {
armPts.push([x, y, z]);
var dx2 = SIG * (y - x), dy2 = x * (RHO - z) - y, dz2 = x * y - BETA * z;
x += dx2 * adt; y += dy2 * adt; z += dz2 * adt;
}
})();
function armXZ(i) { return armPts[Math.max(0, Math.min(armPts.length - 1, i))]; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var t0 = -1, lastT = 0;
function frame(now) {
if (t0 < 0) t0 = now;
var tSec = (now - t0) / 1000;
stepAll();
accumulate(tSec / SPIN_L * 6.2832);
blit(tSec);
requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) blit(3);
});
fit();
seedPoints();
if (reduce) {
var i;
for (i = 0; i < 120; i++) { stepAll(); accumulate(0.3); } // reduce — 쌓인 한 장
blit(3);
root.classList.add('is-still');
} else {
requestAnimationFrame(frame);
}
})();// LorenzButterflyWingHero — vanilla 판(run/324/demo/09-lorenz-butterfly-wing-hero/index.html) 의 React 포트.
// 2,400점을 동시 적분해 xz 히스토그램 밀도 구름(감마 램프)을 쌓고,
// 관찰각을 30초 주기(SPIN_L)로 아주 느리게 돌린다. 밝은 궤적 한 줄이 구름 위를 12초 주기로
// 흐르며 은하 팔처럼 보이게 하고, 비네팅과 세 줄 글자로 서비스 대문을 완성한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LorenzButterflyWingHero.module.scss";
export type LorenzButterflyWingHeroProps = {
duration?: string;
easing?: string;
color?: string;
npts?: number; // 동시 점 수 — 밀도 구름 결
spinL?: number; // 관찰각 회전 주기(초) — 설정 30초와 같다
};
// ── 변형 상수(기본값) — vanilla 판과 동일 ──
const NPTS = 2400; // 동시 점 수
const DT = 0.016; // 오일러 걸음 — 프레임당 2회
const SPIN_L = 30.0; // 관찰각 회전 주기(초) — 설정 30초와 같다
const RES = 150; // 밀도 히스토그램 해상도
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "나비 히어로 씬 실험판";
const META_LEFT = "은하 팔 궤적 12초 · 관찰각 30초";
const META_RIGHT = "canvas · 은하 궤적 · 항상 움직임";
const HINT = "밀도 구름을 배경으로 깔고 비네팅·글자·느린 회전을 얹으면 사이언스 서비스 대문 장면이 완성됩니다";
const TITLE = "CHAOS BUTTERFLY";
const SUB1 = "로렌츠 어트랙터 시뮬레이터";
const SUB2 = "dz/dt = xy − βz";
const SIG = 10, RHO = 28, BETA = 8 / 3;
function mix(a: RGB, b: RGB, t: number): RGB {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function rgba(c: RGB, a: number): string {
return "rgba(" + (c[0] | 0) + "," + (c[1] | 0) + "," + (c[2] | 0) + "," + a + ")";
}
function rng(seed: number): () => number {
let t = seed;
return function (): number {
t += 0x6D2B79F5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
// 은하 팔용 사전 적분 궤적 (x, y, z)
function buildArmPts(): number[][] {
const armPts: number[][] = [];
let x = 0.6, y = 0.7, z = 21;
const adt = 0.012;
for (let i = 0; i < 300; i++) {
const dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
x += dx * adt; y += dy * adt; z += dz * adt;
}
for (let i = 0; i < 700; i++) {
armPts.push([x, y, z]);
const dx2 = SIG * (y - x), dy2 = x * (RHO - z) - y, dz2 = x * y - BETA * z;
x += dx2 * adt; y += dy2 * adt; z += dz2 * adt;
}
return armPts;
}
export default function LorenzButterflyWingHero({
duration = "2s",
easing = "ease-out",
color = "#cfe3ff",
npts = NPTS,
spinL = SPIN_L
}: LorenzButterflyWingHeroProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const c0Ref = useRef<RGB | null>(null); // 밀도 낮은 쪽 — 얼음
const c1Ref = useRef<RGB | null>(null); // 밀도 높은 쪽 — 복숭아
const headRef = useRef<RGB | null>(null); // 글자·헤드 — 크림(토큰)
const panelRef = useRef<RGB | null>(null);
const histRef = useRef<Float32Array | null>(null);
const ptsRef = useRef<{ px: Float32Array; py: Float32Array; pz: Float32Array } | null>(null);
const armRef = useRef<number[][] | null>(null);
const wRef = useRef(0) as Box<number>;
const hRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
wRef.current = Math.round(r.width);
hRef.current = Math.round(r.height);
cv.width = wRef.current;
cv.height = hRef.current;
}
function allocPoints(n: number): void {
ptsRef.current = {
px: new Float32Array(n),
py: new Float32Array(n),
pz: new Float32Array(n)
};
}
function seedPoints(): void {
const pts = ptsRef.current;
if (!pts) return;
const rand = rng(99);
for (let i = 0; i < pts.px.length; i++) {
pts.px[i] = 0.6 + (rand() - 0.5) * 0.02;
pts.py[i] = 0.7 + (rand() - 0.5) * 0.02;
pts.pz[i] = 21 + (rand() - 0.5) * 0.02;
}
}
function stepAll(): void {
const pts = ptsRef.current;
if (!pts) return;
const { px, py, pz } = pts;
const N = px.length;
for (let k = 0; k < 2; k++) {
for (let i = 0; i < N; i++) {
const x = px[i], y = py[i], z = pz[i];
const dx = SIG * (y - x), dy = x * (RHO - z) - y, dz = x * y - BETA * z;
px[i] = x + dx * DT; py[i] = y + dy * DT; pz[i] = z + dz * DT;
}
}
}
// 회전 각도에 맞춰 xz 히스토그램에 누적 — 회전은 x/y 축 섞기로 표현
function accumulate(a: number): void {
const pts = ptsRef.current;
const hist = histRef.current;
if (!pts || !hist) return;
const { px, py, pz } = pts;
const ca = Math.cos(a), sa = Math.sin(a);
for (let i = 0; i < px.length; i++) {
const u = px[i] * ca - py[i] * sa;
const gx = (u / 22 + 0.5) * (RES - 1);
const gy = (1 - (pz[i] - 1) / 47) * (RES - 1);
if (gx >= 0 && gx < RES && gy >= 0 && gy < RES) {
hist[(gy | 0) * RES + (gx | 0)] += 1;
}
}
}
function armXZ(i: number): number[] {
const arm = armRef.current;
if (!arm) return [0, 0, 0];
return arm[Math.max(0, Math.min(arm.length - 1, i))];
}
function blit(tSec: number): void {
const ctx = ctxRef.current;
const hist = histRef.current;
const C0 = c0Ref.current;
const C1 = c1Ref.current;
const HEAD = headRef.current;
const PANEL = panelRef.current;
const W = wRef.current, H = hRef.current;
if (!ctx || !hist || !C0 || !C1 || !HEAD || !PANEL) return;
let maxA = 0;
for (let i = 0; i < hist.length; i++) if (hist[i] > maxA) maxA = hist[i];
if (maxA <= 0) maxA = 1;
ctx.fillStyle = rgba(PANEL, 1);
ctx.fillRect(0, 0, W, H);
const side = Math.min(W, H) * 0.96;
const ox = (W - side) / 2, oy = (H - side) / 2;
const cell = side / RES;
ctx.save();
ctx.translate(ox, oy);
for (let j = 0; j < RES; j++) {
for (let k2 = 0; k2 < RES; k2++) {
const v = hist[j * RES + k2];
if (v <= 0) continue;
const g = Math.pow(v / maxA, 0.38);
const col = mix(C0, C1, g);
const alpha = Math.min(1, 0.08 + g * 0.80);
ctx.fillStyle = "rgba(" + (col[0] | 0) + "," + (col[1] | 0) + "," + (col[2] | 0) + "," + alpha + ")";
ctx.fillRect(k2 * cell, j * cell, cell + 0.5, cell + 0.5);
}
}
ctx.restore();
// 은하 팔 — 밝은 단일 궤적이 구름 위를 크리프한다
// (사전 적분 궤적에서 phase 로 읽는 게 아니라, tSec 에 τ 크리프를 태워 머리를 끌고 다닌다)
const armT = (tSec % 12) / 12;
const armN = 700;
const ai = Math.floor(armT * armN);
const ca2 = Math.cos(tSec / SPIN_L * 6.2832), sa2 = Math.sin(tSec / SPIN_L * 6.2832);
ctx.lineCap = "round";
ctx.strokeStyle = rgba(HEAD, 0.5);
ctx.lineWidth = 2.2;
ctx.beginPath();
const a0 = Math.max(0, ai - 160);
for (let i = a0; i <= ai; i++) {
const p2 = armXZ(i);
const u2 = p2[0] * ca2 - p2[1] * sa2;
const hx2 = ox + (u2 / 22 + 0.5) * side;
const hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
if (i === a0) ctx.moveTo(hx2, hy2); else ctx.lineTo(hx2, hy2);
}
ctx.stroke();
// 팔 머리 — 크림 점
if (ai >= 0) {
const p2 = armXZ(ai);
const u2 = p2[0] * ca2 - p2[1] * sa2;
const hx2 = ox + (u2 / 22 + 0.5) * side;
const hy2 = oy + (1 - (p2[2] - 1) / 47) * side;
ctx.fillStyle = rgba(HEAD, 0.25);
ctx.beginPath(); ctx.arc(hx2, hy2, 6, 0, 6.2832); ctx.fill();
ctx.fillStyle = rgba(HEAD, 1);
ctx.beginPath(); ctx.arc(hx2, hy2, 2.2, 0, 6.2832); ctx.fill();
}
// 비네팅 — 가장자리를 어둡게 눌러 글자가 뜨게 한다
const vg = ctx.createRadialGradient(W / 2, H * 0.46, Math.min(W, H) * 0.28, W / 2, H / 2, Math.max(W, H) * 0.75);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(4,9,16,0.62)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
// 타이포 — 대문 세 줄
ctx.textAlign = "center";
ctx.fillStyle = rgba(HEAD, 0.94);
ctx.font = '700 15px -apple-system, "Segoe UI", sans-serif';
ctx.fillText(TITLE, W / 2, H * 0.40);
ctx.fillStyle = rgba(HEAD, 0.62);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText(SUB1, W / 2, H * 0.40 + 18);
ctx.fillStyle = rgba(HEAD, 0.40);
ctx.font = '500 10px -apple-system, "Segoe UI", sans-serif';
ctx.fillText(SUB2, W / 2, H * 0.40 + 33);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const tSec = (now - t0) / 1000;
stepAll();
accumulate(tSec / SPIN_L * 6.2832);
blit(tSec);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
const cv = cvRef.current;
if (!root || !cv) return;
c0Ref.current = tokRGB(root, "--lz-c0");
c1Ref.current = tokRGB(root, "--lz-c1");
headRef.current = tokRGB(root, "--lz-head");
panelRef.current = tokRGB(root, "--lz-panel");
ctxRef.current = cv.getContext("2d");
histRef.current = new Float32Array(RES * RES);
allocPoints(npts);
armRef.current = buildArmPts();
fit();
seedPoints();
function onResize(): void {
fit();
if (reduced()) blit(3);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let i = 0; i < 120; i++) { stepAll(); accumulate(0.3); } // reduce — 쌓인 한 장
blit(3);
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [npts, spinL]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<span>{HINT}</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}>{META_LEFT}</span>
<span className={styles.metaDim}>{META_RIGHT}</span>
</div>
</div>
</main>
);
}09 · 蝶のヒーローシーン — 文字・粒子と重ねる表紙の場面 — 計測 累積面積 10.849% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
銀河の腕 — 前もって積分した1本の軌道
腕は雲とは別に、dt 0.012で700点を前もって積分した1本の軌道から読み出します。armT = (tSec % 12) / 12 が12秒周期で頭の位置を決め、後ろの160点が尾として引かれます。
// 銀河の腕用の事前積分軌道 (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;
}
})();
回転するヒストグラムとビネット
雲の accumulate は観察角 a で x・y を混ぜてからヒストグラムに落とすので、点群はそのままに見え方だけが30秒かけて回ります。縁はビネットで抑え、文字はクリーム色の HEAD トークンで3段の濃さに重ねました。
オイラーのdtを0.05以上にすると、そこで壊れる落とし穴
オイラーの dt を大きく取る
dt 0.05以上で1歩の誤差が歩ごとに積み重なり、軌道が外へ突き出て蝶が崩れます。03が dt 0.02 でオイラーと RK4 を比べるのはこのためです。逆に雲・つまみのように毎フレーム2,000点前後を積分し直す項目では、dt を小さくしたオイラーの方がフレーム予算に合います。RK4 は1歩あたり傾きを4回計算するからです。
02のスケールを一辺で括る
キャンバスは2.4:1なのにアトラクターは1:1に近いので、min(W, H) で括ると横の半分以上が空きます。横 W/57・縦 H/37.5 の異方性スケールこそ累積面積28.715%の鍵で、正方形スケールに戻すだけで画面が痩せます。
ポアンカレの通過をフレーム単位で検査する
dt 0.005 で密に積分しても、前の z と今の z の間のまたぎを線形補間しないと点が遠くにずれます。この補間こそが地図の精度を決めます。
zipを開くと九つの項目の index.html と style.css がフォルダーごと入っています。展開に使うパスワードは32hdtxzfです。本文に引用した関数がコメント込みでそのまま入っているので、行単位で突き合わせて確かめられます。
3つの変形のひとつが、そのままreduced-motionのアクセシビリティ対応になる
| 名前 | 変更する値 | 変わる感触 |
|---|---|---|
| 時間ステップ | 01 DT 0.01 → 0.005 | 歩幅が半分になると軌道が滑らかになり、同じ時間を埋める歩数が2倍になる |
| 係数ロー | 08 ρ 28 → 35 | 蝶の羽がさらに開き、軌道が外へ走り去る周期が目に見えて短くなる |
| 点の数 | 06 NPTS 2,000 → 8,000 | 霧が濃くなり、羽の内側の構造が夜空の銀河のように明るくなる |
定数ブロックの1行を書き換えるだけ
3つとも書き換えるのはソース先頭の変数ブロックの1行だけです。時間ステップは精度、係数は世界そのもの、点の数は統計の密度という、それぞれ別の性格のつまみです。
動きを減らす設定で残る1枚
九つの盤すべてに prefers-reduced-motion: reduce の分岐が組み込まれており、ループを回さず積み上がった1場面を描いて .is-still クラスを付けます。静止していても蝶が見えるので、動きを止めた訪問者にも各盤の結論の場面が届きます。
触れる1枚と完結する8枚
08だけがポインタ入力を受け付け、ほかの8つは手を触れなくても完結します。08のポインタ移動は pointermove で受け、画面の下のメタ行で dt・歩数・周期を読めます。
この画面はどこで使う — 蝶のヒーロー部とパラメータ遊び場
九つの盤のうち、そのまま画面に載せられる組み合わせは3つに絞られます。09の蝶のヒーローシーンは文字と粒子を密度の霧に重ねる表紙の場面なので、製品紹介ページの最初の画面や講座サイトの導入にそのまま使え、背景が暗いヒーロー領域で特に映えます。
08のパラメータのつまみはポインタでσ・ρを直接ひねって形が変わる盤なので、インタラクティブなデモ枠や訪問者に係数を触らせたい教材ページに向きます。08自体の発散ガードがすでに効いているので、追加の例外処理なしでそのまま流用できます。
06の密度の雲は訪問回数を輝度として積む霧の質感なので、カードの背景やセクションの区切りの裏に薄く敷く用途に向きます。点の数を減らせば計算量が下がり、背景用途に見合う軽い盤になります。
ローレンツアトラクターを初めて触るときによくある質問 FAQ
ローレンツアトラクタとは 何ですか? — 簡単に説明できますか?
3つの速度式 dx/dt = σ(y−x)・dy/dt = x(ρ−z)−y・dz/dt = xy−βz に従って点を動かすと、2つの羽の間を行き来する軌道が勝手に積もります。その積もった軌道の形をローレンツアトラクターと呼びます。絵を持ってくるのではなく、式を積分すると画面に現れるところがポイントです。
計算をそのままコピーして自分のページに置けますか?
置けます。zipの中から好きな盤1つのフォルダーを取り出して、index.html と style.css の2ファイルをそのままアップロードすれば動きます。ライブラリも WebGL も CDN も使っていないので、読み込みの設定は何も要りません。
なぜ勝手に乱れているように見えるのですか?
式自体は決定論的で、同じ初期値と歩幅からは必ず同じ軌道が再現されます。乱れて見えるのは、初期値の下の桁に隠れた小さな差が時間とともに指数的に広がる敏感性のためで、06の2,000点の雲が広がる様子がそれを見せる場面です。
あらためて — なぜ同じ式で毎回違う道をたどるのか
初期値をどれだけ正確に覚えても、予測はずれます。ローレンツ系の速度式に微小な差を持つ2点を放つと、差は時間とともに指数的に広がり、しばらくすると2点は別々の羽にいます。06の2,000点がすべて (0.6, 0.7, 21) の周り ±0.01 から出発して夜空の銀河のように広がるのは、この敏感性を統計として眺めている場面です。決して乱数ではありません。式は決定論的で、同じ初期値から同じ歩幅で積分すれば同じ軌道が毎回現れます。それでも予測できないのは、初期値を有限の桁でしか書けないためで、桁の下に隠れた差がやがて全体の形を左右します。カオス アトラクター と呼ばれる絵の正体は、この予測不可能性と決定論が1本の軌道に同居していることです。03でオイラー盤が崩れるのも同族の現象で、歩幅の誤差が初期値の誤差と同じ役割を果たして数周後に形を入れ替えます。









