2
Hénon Map: Folded Attractor
drag Y to scrub a
idle
145 lines · vanilla
view source
// Hénon map: x_{n+1} = 1 - a x_n^2 + y_n, y_{n+1} = b x_n
// Accumulates ~60k iterates per frame into a fixed-size density buffer,
// fades it slowly, then drawImage-scales onto the visible canvas.
// Mouse Y latches a in [1.0, 1.4]; auto-sweeps when idle.
const B = 0.3;
const ITERS_PER_FRAME = 60000;
const FADE = 0.965;
const BUF_W = 720;
const BUF_H = 720;
const IDLE_MS = 1200;
let W = 0, H = 0;
let bufCanvas, bufCtx, bufImg, bufData;
let density;
let palette;
let x = 0.0, y = 0.0;
let a = 1.4;
let aDisplay = 1.4;
let lastMouseX = -1, lastMouseY = -1;
let lastMoveTime = -10000;
let sweepPhase = 0;
function buildPalette() {
// dark -> deep purple -> magenta -> cyan -> near-white
palette = new Uint8Array(1024 * 4);
const stops = [
[0.00, 3, 4, 16],
[0.10, 16, 6, 44],
[0.28, 78, 10, 110],
[0.48, 188, 30, 168],
[0.66, 232, 78, 200],
[0.82, 90, 210, 232],
[1.00, 230, 250, 255],
];
for (let i = 0; i < 1024; i++) {
const t = i / 1023;
let A = stops[0], C = stops[stops.length - 1];
for (let k = 0; k < stops.length - 1; k++) {
if (t >= stops[k][0] && t <= stops[k + 1][0]) {
A = stops[k]; C = stops[k + 1]; break;
}
}
const u = (t - A[0]) / (C[0] - A[0] || 1);
palette[i * 4] = (A[1] + (C[1] - A[1]) * u) | 0;
palette[i * 4 + 1] = (A[2] + (C[2] - A[2]) * u) | 0;
palette[i * 4 + 2] = (A[3] + (C[3] - A[3]) * u) | 0;
palette[i * 4 + 3] = 255;
}
}
function init({ canvas, ctx, width, height }) {
W = width; H = height;
buildPalette();
bufCanvas = new OffscreenCanvas(BUF_W, BUF_H);
bufCtx = bufCanvas.getContext('2d');
bufImg = bufCtx.createImageData(BUF_W, BUF_H);
bufData = bufImg.data;
density = new Float32Array(BUF_W * BUF_H);
// alpha = 255 throughout
for (let i = 3; i < bufData.length; i += 4) bufData[i] = 255;
x = 0.1; y = 0.0;
// warm up off-attractor transient
for (let i = 0; i < 200; i++) {
const nx = 1 - 1.4 * x * x + y;
y = B * x;
x = nx;
}
ctx.fillStyle = '#03040e';
ctx.fillRect(0, 0, W, H);
}
function updateA({ input, time }) {
// Detect mouse motion to decide latched vs auto-sweep.
const mx = input.mouseX, my = input.mouseY;
if (mx !== lastMouseX || my !== lastMouseY) {
lastMouseX = mx; lastMouseY = my;
lastMoveTime = time;
}
const idle = (time - lastMoveTime) > IDLE_MS;
let target;
if (!idle && my >= 0 && my <= H) {
// map Y to a in [1.0, 1.4]
target = 1.0 + (my / H) * 0.4;
} else {
// auto-sweep across the same range
sweepPhase += 0.0035;
target = 1.2 + 0.2 * Math.sin(sweepPhase);
}
// smooth toward target so changes don't flash
aDisplay += (target - aDisplay) * 0.08;
a = aDisplay;
}
function tick({ ctx, time, width, height, input }) {
if (width !== W || height !== H) {
W = width; H = height;
}
updateA({ input, time });
// Fade density.
for (let i = 0; i < density.length; i++) density[i] *= FADE;
// Iterate and accumulate.
// Center the canonical attractor (roughly x in [-1.5,1.5], y in [-0.45,0.45])
// into the square buffer with a margin.
const cx = BUF_W * 0.5;
const cy = BUF_H * 0.5;
const sx = BUF_W * 0.30; // x scale
const sy = BUF_H * 1.05; // y scale (y range is much smaller)
let lx = x, ly = y;
for (let i = 0; i < ITERS_PER_FRAME; i++) {
const nx = 1 - a * lx * lx + ly;
ly = B * lx;
lx = nx;
// bail if the orbit escaped (large a or numerical fluke)
if (lx < -10 || lx > 10) { lx = 0.1; ly = 0.0; continue; }
const px = (cx + lx * sx) | 0;
const py = (cy - ly * sy) | 0;
if (px >= 0 && px < BUF_W && py >= 0 && py < BUF_H) {
density[py * BUF_W + px] += 1.0;
}
}
x = lx; y = ly;
// Find max (strided sample).
let max = 0;
const stride = 17;
for (let i = 0; i < density.length; i += stride) {
const v = density[i];
if (v > max) max = v;
}
if (max < 1) max = 1;
const invLog = 1 / Math.log1p(max);
// Density -> palette.
for (let i = 0; i < density.length; i++) {
const v = density[i];
const o = i * 4;
if (v <= 0.01) {
bufData[o] = 3;
bufData[o + 1] = 4;
bufData[o + 2] = 16;
continue;
}
let t = Math.log1p(v) * invLog;
if (t > 1) t = 1;
t = Math.pow(t, 0.62);
const p = ((t * 1023) | 0) * 4;
bufData[o] = palette[p];
bufData[o + 1] = palette[p + 1];
bufData[o + 2] = palette[p + 2];
}
bufCtx.putImageData(bufImg, 0, 0);
// Scale buffer to the visible canvas.
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bufCanvas, 0, 0, BUF_W, BUF_H, 0, 0, W, H);
// HUD: live a value, top-left.
ctx.font = 'bold 14px ui-monospace, Menlo, monospace';
ctx.textBaseline = 'top';
const label = 'a = ' + a.toFixed(4) + ' b = ' + B.toFixed(2);
const pad = 8;
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(0,0,0,0.45)';
ctx.fillRect(8, 8, tw + pad * 2, 24);
ctx.fillStyle = '#e8f6ff';
ctx.fillText(label, 8 + pad, 12);
// Hint when idle.
const idle = (time - lastMoveTime) > IDLE_MS;
if (idle) {
ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
ctx.fillStyle = 'rgba(232,246,255,0.55)';
ctx.fillText('drag vertically to scrub a', 8 + pad, 36);
}
}
Comments (0)
Log in to comment.