07ポアンカレ断面 — 軌道が平面を横切る瞬間だけを打つ
軌道が基準平面を横切る瞬間の座標だけに点を打つと、羽ごとに点の雲が帯になります。3次元の流れを2次元の地図へ折りたたむ古典的技法を画面に移した項目です。
使いどころ教育インタラクティブ、サイエンスコンテンツ
requestAnimationFramelineToMath.min

<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 に同梱 · ローレンツアトラクター 9種 — 蝶の形のカオス軌道を1歩ずつ数値積分して積む
このデモが属する記事
ローレンツアトラクター 9種 — 蝶の形のカオス軌道を1歩ずつ数値積分して積む3つの微分方程式を1歩ずつ積分すると、蝶の形をした軌道がひとりでに積み上がります。ライブラリなしのコピペで持ち帰れるカオス可視化です。ローレンツアトラクターとは、蝶の絵を持ってきた画像ではなく、速度式3行に点の位置を従わせた計算の結果です。 この記事のデモをすべて見る


