Fractal Explorer
ling 3.0 Flash · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 36,373 bytes · SHA-256 7cd2b28df5cb
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{overflow:hidden;background:#000;font-family:'SF Mono','Cascadia Code','Consolas',monospace;color:#e0e0e0;user-select:none}
canvas{display:block;position:fixed;top:0;left:0}
#fractal{z-index:1}
#overlay{z-index:2;pointer-events:none}
#juliaPreview{position:fixed;bottom:16px;right:16px;width:220px;height:220px;border:2px solid rgba(255,255,255,.25);border-radius:10px;background:#0a0a0a;z-index:3;image-rendering:pixelated}
#hud{position:fixed;top:12px;left:12px;z-index:4;pointer-events:none;font-size:12px;line-height:1.7;background:rgba(10,10,20,.65);padding:8px 14px;border-radius:8px;backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.08);transition:opacity .4s;opacity:1}
#hud.off{opacity:0}
#hud .lbl{color:rgba(255,255,255,.45);font-size:10px;text-transform:uppercase;letter-spacing:.5px}
#hud .val{color:#fff;font-weight:600}
#hud .row{display:flex;gap:8px;align-items:baseline}
#hud .sep{color:rgba(255,255,255,.15);margin:0 4px}
#controls{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);z-index:5;display:flex;gap:6px;align-items:center;background:rgba(10,10,20,.75);padding:8px 14px;border-radius:12px;backdrop-filter:blur(8px);border:1px solid rgba(255,255,255,.08);transition:opacity .3s}
#controls.hidden{opacity:0;pointer-events:none}
#controls button,#controls select,#controls input{background:rgba(255,255,255,.07);color:#ccc;border:1px solid rgba(255,255,255,.12);border-radius:5px;padding:4px 10px;font-size:11px;font-family:inherit;cursor:pointer;outline:none}
#controls button:hover,#controls button:focus{background:rgba(255,255,255,.15);color:#fff}
#controls button.active{background:rgba(255,255,255,.2);color:#fff;border-color:rgba(255,255,255,.3)}
#controls input[type=range]{width:70px;accent-color:#6af}
#controls input[type=range]::-webkit-slider-thumb{cursor:pointer}
.swatch{width:22px;height:22px;border-radius:4px;cursor:pointer;border:2px solid transparent;transition:border-color .15s,transform .15s}
.swatch:hover{transform:scale(1.15)}
.swatch.on{border-color:#fff;box-shadow:0 0 6px rgba(255,255,255,.4)}
#zoomBox{position:fixed;z-index:3;pointer-events:none;border:2px solid rgba(255,255,255,.85);background:rgba(255,255,255,.06);display:none}
#tooltip{position:fixed;z-index:6;pointer-events:none;background:rgba(10,10,20,.85);color:#ddd;font-size:11px;padding:3px 8px;border-radius:4px;display:none;backdrop-filter:blur(4px);border:1px solid rgba(255,255,255,.1)}
.kbd{display:inline-block;padding:1px 5px;border-radius:3px;background:rgba(255,255,255,.1);font-size:10px;border:1px solid rgba(255,255,255,.15)}
</style>
</head>
<body>
<canvas id="fractal"></canvas>
<canvas id="overlay"></canvas>
<canvas id="juliaPreview"></canvas>
<div id="hud"></div>
<div id="zoomBox"></div>
<div id="tooltip"></div>
<div id="controls">
<span class="lbl">Set</span>
<button id="btnMB" class="active">Mandelbrot</button>
<button id="btnJulia">Julia</button>
<span class="sep">|</span>
<span class="lbl">Palette</span>
<div id="swatches" style="display:flex;gap:4px"></div>
<span class="sep">|</span>
<span class="lbl">Iter</span>
<input type="range" id="iterSlider" min="30" max="5000" value="200" step="10">
<span id="iterVal" style="font-size:11px;min-width:36px">200</span>
<span class="sep">|</span>
<span class="lbl">Cycle</span>
<input type="range" id="cycleSlider" min="0" max="200" value="0" step="1" style="width:55px">
<span class="sep">|</span>
<button id="btnBack" title="Back (B)">◀ Back</button>
<button id="btnReset" title="Reset (R)">⌂ Reset</button>
<button id="btnSave" title="Save Bookmark (S)">★ Save</button>
<button id="btnLoad" title="Load Bookmark (L)">♥ Load</button>
</div>
<script>
// =============================================
// TUNABLE CONSTANTS
// =============================================
const CONFIG = {
// Rendering
baseMaxIter: 100, // Base iteration count at default zoom
iterPerOctave: 50, // Extra iterations per octave of zoom
maxMaxIter: 8000, // Hard cap on iterations
progressivePasses: [0.25, 0.5, 1.0], // Resolution scales for progressive render
chunkRows: 48, // Rows per frame in full-res progressive pass
zoomSpeed: 1.8, // Wheel zoom factor per tick
panSpeed: 0.003, // Pan fraction of view per arrow key
maxMagnification: 1e14, // Cap beyond which precision degrades
// Julia preview
juliaPreviewSize: 220,
// Colors
interiorColor: [0, 0, 0], // Black for set interior
// History
maxHistory: 60,
};
// =============================================
// STATE
// =============================================
const state = {
view: { re: -0.5, im: 0, scale: 2.5 }, // Default: full Mandelbrot
setType: 'mandelbrot',
juliaC: { re: -0.7, im: 0.27015 },
palette: 'fire',
paletteCycle: 0,
maxIter: 200,
manualIter: false,
// Interaction
isDragging: false,
dragStart: null,
dragCurrent: null,
isPanning: false,
panStart: null,
panVelocity: { x: 0, y: 0 },
mouseX: -1,
mouseY: -1,
// Render
renderGen: 0,
renderPending: false,
// History
history: [],
histIndex: -1,
// HUD
hudVisible: true,
hudTimer: 0,
fps: 0,
frameCount: 0,
lastFpsTime: performance.now(),
};
// =============================================
// CANVAS SETUP
// =============================================
const fractalCanvas = document.getElementById('fractal');
const fractalCtx = fractalCanvas.getContext('2d', { alpha: false });
const overlayCanvas = document.getElementById('overlay');
const overlayCtx = overlayCanvas.getContext('2d', { alpha: false });
const juliaCanvas = document.getElementById('juliaPreview');
const juliaCtx = juliaCanvas.getContext('2d', { alpha: false });
let W, H; // Canvas logical dimensions
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
W = window.innerWidth;
H = window.innerHeight;
fractalCanvas.width = W * dpr;
fractalCanvas.height = H * dpr;
fractalCanvas.style.width = W + 'px';
fractalCanvas.style.height = H + 'px';
fractalCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
overlayCanvas.width = W * dpr;
overlayCanvas.height = H * dpr;
overlayCanvas.style.width = W + 'px';
overlayCanvas.style.height = H + 'px';
overlayCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
const pSize = CONFIG.juliaPreviewSize;
juliaCanvas.width = pSize;
juliaCanvas.height = pSize;
juliaCanvas.style.width = pSize + 'px';
juliaCanvas.style.height = pSize + 'px';
scheduleRender();
}
window.addEventListener('resize', resizeCanvas);
// =============================================
// MATH / ITERATION (allocation-free hot loop)
// =============================================
/** Compute Mandelbrot escape iterations for pixel (x, y). */
function mandelbrotIter(cr, ci, maxIter) {
let zr = 0, zi = 0;
let zr2 = 0, zi2 = 0;
let iter = 0;
while (iter < maxIter && zr2 + zi2 <= 4) {
// z = z^2 + c
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
iter++;
}
if (iter >= maxIter) return maxIter;
// Smooth (continuous) coloring: n + 1 - log2(log|z|)
const logMod = Math.log(zr2 + zi2) * 0.5;
return iter + 1 - Math.log(logMod) * (1 / Math.LN2);
}
/** Compute Julia escape iterations for pixel (x, y) with fixed c. */
function juliaIter(zr, zi, cr, ci, maxIter) {
let zr2 = zr * zr, zi2 = zi * zi;
let iter = 0;
while (iter < maxIter && zr2 + zi2 <= 4) {
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
iter++;
}
if (iter >= maxIter) return maxIter;
const logMod = Math.log(zr2 + zi2) * 0.5;
return iter + 1 - Math.log(logMod) * (1 / Math.LN2);
}
/** Get max iterations based on zoom depth (scales with magnification). */
function getAdaptiveMaxIter(magnification) {
if (state.manualIter) return state.maxIter;
const extra = Math.log2(Math.max(1, magnification)) * CONFIG.iterPerOctave;
return Math.min(CONFIG.maxMaxIter, Math.ceil(CONFIG.baseMaxIter + extra));
}
/** Compute magnification relative to default view. */
function getMagnification() {
return 2.5 / state.view.scale;
}
// =============================================
// COLOR PALETTES
// =============================================
const PALETTES = {
fire: [
[0,0,0],[30,0,0],[100,20,0],[200,60,0],[255,140,10],[255,220,80],[255,255,200],[255,255,255]
],
ocean: [
[0,0,0],[0,5,30],[0,20,80],[0,60,140],[20,130,200],[80,200,240],[160,230,255],[230,250,255]
],
mono: [
[0,0,0],[30,30,30],[80,80,80],[160,160,160],[220,220,220],[255,255,255]
],
psychedelic: null // Generated dynamically via sine functions
};
/** Interpolate palette color at position t (0..1). */
function paletteColor(t, palName) {
t = ((t % 1) + 1) % 1; // Wrap
const pal = PALETTES[palName];
if (palName === 'psychedelic') {
// Cyclic RGB via sine waves — no palette stops needed
const r = Math.round((Math.sin(t * Math.PI * 2 * 3 + 0) * 0.5 + 0.5) * 255);
const g = Math.round((Math.sin(t * Math.PI * 2 * 3 + 2.094) * 0.5 + 0.5) * 255);
const b = Math.round((Math.sin(t * Math.PI * 2 * 3 + 4.189) * 0.5 + 0.5) * 255);
return [r, g, b];
}
const n = pal.length - 1;
const i = Math.min(Math.floor(t * n), n - 1);
const f = t * n - i;
return [
Math.round(pal[i][0] + (pal[i + 1][0] - pal[i][0]) * f),
Math.round(pal[i][1] + (pal[i + 1][1] - pal[i][1]) * f),
Math.round(pal[i][2] + (pal[i + 1][2] - pal[i][2]) * f)
];
}
/** Map a smooth iteration value to an RGB color. */
function getColor(smoothIter, maxIter) {
if (smoothIter >= maxIter) return CONFIG.interiorColor;
// Map to [0,1] with cycle offset
const t = (smoothIter * 0.08 + state.paletteCycle * 0.01) % 1;
return paletteColor(t, state.palette);
}
// =============================================
// COORDINATE TRANSFORMS
// =============================================
/** Convert canvas pixel (px, py) to complex coordinates. */
function pixelToComplex(px, py) {
const v = state.view;
const aspect = H / W;
const re = v.re + (px / W - 0.5) * 2 * v.scale;
const im = v.im + (0.5 - py / H) * 2 * v.scale * aspect;
return { re, im };
}
/** Convert complex coordinates to canvas pixel (px, py). */
function complexToPixel(re, im) {
const v = state.view;
const aspect = H / W;
const px = ((re - v.re) / (2 * v.scale) + 0.5) * W;
const py = ((v.im - im) / (2 * v.scale * aspect) + 0.5) * H;
return { x: px, y: py };
}
// =============================================
// RENDERING ENGINE
// =============================================
// Reusable full-res ImageData for final pass
let fullImgData = null;
let fullImgDataW = 0, fullImgDataH = 0;
function getFullImageData() {
if (!fullImgData || fullImgDataW !== W || fullImgDataH !== H) {
fullImgData = fractalCtx.createImageData(W, H);
fullImgDataW = W;
fullImgDataH = H;
}
return fullImgData;
}
/** Render fractal at a given resolution scale into a temp canvas, return the canvas. */
function renderToTempCanvas(scale, maxIter) {
const pw = Math.max(1, Math.ceil(W * scale));
const ph = Math.max(1, Math.ceil(H * scale));
const tempCanvas = document.createElement('canvas');
tempCanvas.width = pw;
tempCanvas.height = ph;
const tempCtx = tempCanvas.getContext('2d');
const tempImg = tempCtx.createImageData(pw, ph);
const data = tempImg.data;
const v = state.view;
const aspect = H / W;
const halfW = v.scale;
const halfH = v.scale * aspect;
const pixelSizeRe = (2 * halfW) / pw;
const pixelSizeIm = (2 * halfH) / ph;
const startRe = v.re - halfW;
const startIm = v.im + halfH;
const isJulia = state.setType === 'julia';
const cr = state.juliaC.re;
const ci = state.juliaC.im;
for (let py = 0; py < ph; py++) {
const im = startIm - py * pixelSizeIm;
for (let px = 0; px < pw; px++) {
const re = startRe + px * pixelSizeRe;
let smoothIter;
if (isJulia) {
smoothIter = juliaIter(re, im, cr, ci, maxIter);
} else {
smoothIter = mandelbrotIter(re, im, maxIter);
}
const color = getColor(smoothIter, maxIter);
const idx = (py * pw + px) * 4;
data[idx] = color[0];
data[idx + 1] = color[1];
data[idx + 2] = color[2];
data[idx + 3] = 255;
}
}
tempCtx.putImageData(tempImg, 0, 0);
return tempCanvas;
}
/** Render the full-resolution pass in chunks (progressive). */
function renderFullResChunked(maxIter, myGen) {
const imageData = getFullImageData();
const data = imageData.data;
// Initialize to interior color (black)
data.fill(0);
const v = state.view;
const aspect = H / W;
const halfW = v.scale;
const halfH = v.scale * aspect;
const pixelSizeRe = (2 * halfW) / W;
const pixelSizeIm = (2 * halfH) / H;
const startRe = v.re - halfW;
const startIm = v.im + halfH;
const isJulia = state.setType === 'julia';
const cr = state.juliaC.re;
const ci = state.juliaC.im;
let y = 0;
const rowsPerChunk = CONFIG.chunkRows;
function chunk() {
if (state.renderGen !== myGen) return; // Cancelled
const endY = Math.min(y + rowsPerChunk, H);
for (let row = y; row < endY; row++) {
const im = startIm - row * pixelSizeIm;
for (let col = 0; col < W; col++) {
const re = startRe + col * pixelSizeRe;
let smoothIter;
if (isJulia) {
smoothIter = juliaIter(re, im, cr, ci, maxIter);
} else {
smoothIter = mandelbrotIter(re, im, maxIter);
}
const color = getColor(smoothIter, maxIter);
const idx = (row * W + col) * 4;
data[idx] = color[0];
data[idx + 1] = color[1];
data[idx + 2] = color[2];
data[idx + 3] = 255;
}
}
fractalCtx.putImageData(imageData, 0, 0);
y = endY;
if (y < H) {
requestAnimationFrame(chunk);
} else {
state.renderPending = false;
}
}
requestAnimationFrame(chunk);
}
/** Main render entry point — progressive multi-pass with cancellation. */
function doRender() {
const maxIter = getAdaptiveMaxIter(getMagnification());
const myGen = state.renderGen;
state.renderPending = true;
const passes = CONFIG.progressivePasses;
let passIdx = 0;
function nextPass() {
if (state.renderGen !== myGen) { state.renderPending = false; return; }
if (passIdx >= passes.length) { state.renderPending = false; return; }
const scale = passes[passIdx];
passIdx++;
if (scale < 1.0) {
// Progressive pass: render at lower resolution, draw scaled up
const tempCanvas = renderToTempCanvas(scale, maxIter);
fractalCtx.imageSmoothingEnabled = false; // Pixel-perfect for fractal detail
fractalCtx.drawImage(tempCanvas, 0, 0, tempCanvas.width, tempCanvas.height, 0, 0, W, H);
requestAnimationFrame(nextPass);
} else {
// Full-res pass: chunked rendering
renderFullResChunked(maxIter, myGen);
}
}
requestAnimationFrame(nextPass);
}
function scheduleRender() {
state.renderGen++;
state.renderPending = false; // Always allow a new render to start
doRender();
}
// =============================================
// NAVIGATION / INTERACTION
// =============================================
function pushHistory() {
const entry = {
re: state.view.re,
im: state.view.im,
scale: state.view.scale,
setType: state.setType,
juliaC: { ...state.juliaC },
palette: state.palette,
paletteCycle: state.paletteCycle,
maxIter: state.maxIter,
manualIter: state.manualIter,
};
// Trim forward history
state.history = state.history.slice(0, state.histIndex + 1);
state.history.push(entry);
if (state.history.length > CONFIG.maxHistory) state.history.shift();
state.histIndex = state.history.length - 1;
}
function applyView(v) {
state.view.re = v.re;
state.view.im = v.im;
state.view.scale = Math.max(1e-15, v.scale); // Prevent degenerate scale
// Clamp magnification
const mag = getMagnification();
if (mag > CONFIG.maxMagnification) {
const factor = mag / CONFIG.maxMagnification;
state.view.scale *= factor;
}
pushHistory();
scheduleRender();
updateHUD();
saveStateToHash();
}
function zoomAtPoint(factor, px, py) {
const v = state.view;
const c = pixelToComplex(px, py);
const newScale = v.scale / factor;
// Adjust center so the point under cursor stays fixed
const aspect = H / W;
const newRe = c.re + (v.re - c.re) / factor;
const newIm = c.im + (v.im - c.im) / factor;
applyView({ re: newRe, im: newIm, scale: newScale });
}
function panView(dx, dy) {
const v = state.view;
const aspect = H / W;
v.re -= dx * 2 * v.scale / W;
v.im += dy * 2 * v.scale * aspect / H;
pushHistory();
scheduleRender();
updateHUD();
saveStateToHash();
}
// --- Mouse handlers ---
fractalCanvas.addEventListener('mousedown', e => {
if (e.button === 0) {
// Left click: start zoom selection
state.isDragging = true;
state.dragStart = { x: e.clientX, y: e.clientY };
state.dragCurrent = { x: e.clientX, y: e.clientY };
} else if (e.button === 2 || e.button === 1) {
// Right/middle click: start panning
state.isPanning = true;
state.panStart = { x: e.clientX, y: e.clientY };
state.panVelocity = { x: 0, y: 0 };
fractalCanvas.style.cursor = 'grabbing';
}
e.preventDefault();
});
fractalCanvas.addEventListener('mousemove', e => {
const rect = fractalCanvas.getBoundingClientRect();
state.mouseX = e.clientX - rect.left;
state.mouseY = e.clientY - rect.top;
if (state.isDragging) {
state.dragCurrent = { x: e.clientX, y: e.clientY };
drawZoomBox();
} else if (state.isPanning && state.panStart) {
const dx = e.clientX - state.panStart.x;
const dy = e.clientY - state.panStart.y;
state.panVelocity = { x: dx, y: dy };
panView(dx, dy);
state.panStart = { x: e.clientX, y: e.clientY };
}
// Update Julia preview on hover
if (state.setType === 'mandelbrot') {
updateJuliaPreviewFromHover();
}
updateHUD();
});
fractalCanvas.addEventListener('mouseup', e => {
if (e.button === 0 && state.isDragging) {
state.isDragging = false;
const box = document.getElementById('zoomBox');
box.style.display = 'none';
// Apply zoom to selection
if (state.dragStart && state.dragCurrent) {
const x0 = Math.min(state.dragStart.x, state.dragCurrent.x);
const x1 = Math.max(state.dragStart.x, state.dragCurrent.x);
const y0 = Math.min(state.dragStart.y, state.dragCurrent.y);
const y1 = Math.max(state.dragStart.y, state.dragCurrent.y);
const w = x1 - x0;
const h = y1 - y0;
if (w > 5 && h > 5) {
const c0 = pixelToComplex(x0, y0);
const c1 = pixelToComplex(x1, y1);
const newScale = state.view.scale * (w / W);
const newRe = (c0.re + c1.re) * 0.5;
const newIm = (c0.im + c1.im) * 0.5;
applyView({ re: newRe, im: newIm, scale: newScale });
}
}
state.dragStart = null;
state.dragCurrent = null;
overlayCtx.clearRect(0, 0, W, H);
}
if ((e.button === 2 || e.button === 1) && state.isPanning) {
state.isPanning = false;
state.panStart = null;
fractalCanvas.style.cursor = 'crosshair';
// Momentum pan
if (Math.abs(state.panVelocity.x) > 1 || Math.abs(state.panVelocity.y) > 1) {
startMomentumPan();
}
}
});
fractalCanvas.addEventListener('mouseleave', () => {
if (state.isDragging) {
state.isDragging = false;
document.getElementById('zoomBox').style.display = 'none';
overlayCtx.clearRect(0, 0, W, H);
}
});
fractalCanvas.addEventListener('wheel', e => {
e.preventDefault();
const factor = e.deltaY < 0 ? CONFIG.zoomSpeed : 1 / CONFIG.zoomSpeed;
zoomAtPoint(factor, e.clientX, e.clientY);
}, { passive: false });
fractalCanvas.addEventListener('contextmenu', e => e.preventDefault());
// Momentum pan
let momentumRaf = null;
function startMomentumPan() {
if (momentumRaf) cancelAnimationFrame(momentumRaf);
function tick() {
if (Math.abs(state.panVelocity.x) < 0.3 && Math.abs(state.panVelocity.y) < 0.3) return;
panView(state.panVelocity.x, state.panVelocity.y);
state.panVelocity.x *= 0.92;
state.panVelocity.y *= 0.92;
momentumRaf = requestAnimationFrame(tick);
}
momentumRaf = requestAnimationFrame(tick);
}
// --- Julia preview ---
function updateJuliaPreviewFromHover() {
if (state.mouseX < 0 || state.mouseY < 0) return;
const c = pixelToComplex(state.mouseX, state.mouseY);
// Update Julia c to hover position
state.juliaC = { re: c.re, im: c.im };
renderJuliaPreview();
}
function renderJuliaPreview() {
const pSize = CONFIG.juliaPreviewSize;
const pCanvas = document.createElement('canvas');
pCanvas.width = pSize;
pCanvas.height = pSize;
const pCtx = pCanvas.getContext('2d');
const pImg = pCtx.createImageData(pSize, pSize);
const data = pImg.data;
const cr = state.juliaC.re;
const ci = state.juliaC.im;
const maxIter = Math.min(500, getAdaptiveMaxIter(getMagnification()));
const halfW = 1.5;
const halfH = 1.5;
const pixelSizeRe = (2 * halfW) / pSize;
const pixelSizeIm = (2 * halfH) / pSize;
const startRe = -halfW;
const startIm = halfH;
for (let py = 0; py < pSize; py++) {
const im = startIm - py * pixelSizeIm;
for (let px = 0; px < pSize; px++) {
const re = startRe + px * pixelSizeRe;
const smoothIter = juliaIter(re, im, cr, ci, maxIter);
const color = getColor(smoothIter, maxIter);
const idx = (py * pSize + px) * 4;
data[idx] = color[0];
data[idx + 1] = color[1];
data[idx + 2] = color[2];
data[idx + 3] = 255;
}
}
pCtx.putImageData(pImg, 0, 0);
juliaCtx.clearRect(0, 0, pSize, pSize);
juliaCtx.drawImage(pCanvas, 0, 0);
// Draw crosshair if in Julia mode
if (state.setType === 'julia') {
const center = complexToPixel(cr, ci);
juliaCtx.strokeStyle = 'rgba(255,255,255,.6)';
juliaCtx.lineWidth = 1;
juliaCtx.beginPath();
juliaCtx.moveTo(center.x, 0);
juliaCtx.lineTo(center.x, pSize);
juliaCtx.moveTo(0, center.y);
juliaCtx.lineTo(pSize, center.y);
juliaCtx.stroke();
}
}
// --- Keyboard ---
document.addEventListener('keydown', e => {
const key = e.key;
const panAmt = state.view.scale * CONFIG.panSpeed;
switch (key) {
case 'ArrowUp': panView(0, -panAmt * H); e.preventDefault(); break;
case 'ArrowDown': panView(0, panAmt * H); e.preventDefault(); break;
case 'ArrowLeft': panView(-panAmt * W, 0); e.preventDefault(); break;
case 'ArrowRight': panView(panAmt * W, 0); e.preventDefault(); break;
case '+': case '=': zoomAtPoint(1.5, W / 2, H / 2); e.preventDefault(); break;
case '-': case '_': zoomAtPoint(1 / 1.5, W / 2, H / 2); e.preventDefault(); break;
case 'j': case 'J': toggleSetType(); e.preventDefault(); break;
case 'r': case 'R': resetView(); e.preventDefault(); break;
case 'b': case 'B': saveBookmark(); e.preventDefault(); break;
case 's': case 'S': saveBookmark(); e.preventDefault(); break;
case 'l': case 'L': loadBookmark(); e.preventDefault(); break;
case 'h': case 'H': toggleHUD(); e.preventDefault(); break;
case '1': setPalette('fire'); e.preventDefault(); break;
case '2': setPalette('ocean'); e.preventDefault(); break;
case '3': setPalette('mono'); e.preventDefault(); break;
case '4': setPalette('psychedelic'); e.preventDefault(); break;
}
});
// =============================================
// UI / HUD
// =============================================
const hudEl = document.getElementById('hud');
function updateHUD() {
const v = state.view;
const mag = getMagnification();
const maxIter = getAdaptiveMaxIter(mag);
const magStr = mag >= 1e6 ? (mag / 1e6).toFixed(1) + 'M' : mag >= 1e3 ? (mag / 1e3).toFixed(1) + 'k' : mag.toFixed(1);
hudEl.innerHTML =
`<div class="row"><span class="lbl">Set</span><span class="val">${state.setType === 'mandelbrot' ? 'Mandelbrot' : 'Julia'}</span></div>` +
`<div class="row"><span class="lbl">Mag</span><span class="val">×${magStr}</span><span class="sep">·</span><span class="lbl">Center</span><span class="val">${v.re.toExponential(3)} ${v.im >= 0 ? '+' : '−'} ${Math.abs(v.im).toExponential(3)}i</span></div>` +
`<div class="row"><span class="lbl">Iter</span><span class="val">${maxIter}</span><span class="sep">·</span><span class="lbl">Scale</span><span class="val">${v.scale.toExponential(2)}</span><span class="sep">·</span><span class="lbl">FPS</span><span class="val">${state.fps}</span></div>` +
`<div class="row"><span class="lbl">C</span><span class="val">${state.juliaC.re.toFixed(6)} ${state.juliaC.im >= 0 ? '+' : '−'} ${Math.abs(state.juliaC.im).toFixed(6)}i</span></div>`;
}
function toggleHUD() {
state.hudVisible = !state.hudVisible;
hudEl.classList.toggle('off', !state.hudVisible);
document.getElementById('controls').classList.toggle('hidden', !state.hudVisible);
}
// Auto-hide HUD after idle
let hudTimeout = null;
function showHUD() {
state.hudVisible = true;
hudEl.classList.remove('off');
document.getElementById('controls').classList.remove('hidden');
clearTimeout(hudTimeout);
hudTimeout = setTimeout(() => {
state.hudVisible = false;
hudEl.classList.add('off');
document.getElementById('controls').classList.add('hidden');
}, 4000);
}
// --- Zoom box overlay ---
function drawZoomBox() {
if (!state.dragStart || !state.dragCurrent) return;
const x0 = Math.min(state.dragStart.x, state.dragCurrent.x);
const y0 = Math.min(state.dragStart.y, state.dragCurrent.y);
const x1 = Math.max(state.dragStart.x, state.dragCurrent.x);
const y1 = Math.max(state.dragStart.y, state.dragCurrent.y);
const box = document.getElementById('zoomBox');
box.style.display = 'block';
box.style.left = x0 + 'px';
box.style.top = y0 + 'px';
box.style.width = (x1 - x0) + 'px';
box.style.height = (y1 - y0) + 'px';
}
// --- Palette swatches ---
function buildSwatches() {
const container = document.getElementById('swatches');
container.innerHTML = '';
const colors = {
fire: ['#000','#1e0000','#641400','#c83c00','#ff8c0a','#ffdc50','#ffffc8','#fff'],
ocean: ['#000','#00051e','#001450','#003c8c','#1482c8','#50c8f0','#a0e6ff','#e6f8ff'],
mono: ['#000','#1e1e1e','#505050','#a0a0a0','#dcdcdc','#fff'],
psychedelic: ['#ff0000','#ffff00','#00ff00','#00ffff','#0000ff','#ff00ff'],
};
for (const name of Object.keys(colors)) {
const sw = document.createElement('div');
sw.className = 'swatch' + (state.palette === name ? ' on' : '');
sw.title = name.charAt(0).toUpperCase() + name.slice(1) + ' (key ' + (Object.keys(colors).indexOf(name) + 1) + ')';
sw.style.background = `linear-gradient(135deg, ${colors[name][0]}, ${colors[name][3]}, ${colors[name][6]})`;
sw.setAttribute('role', 'button');
sw.setAttribute('aria-label', name + ' palette');
sw.setAttribute('tabindex', '0');
sw.addEventListener('click', () => setPalette(name));
sw.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setPalette(name); } });
container.appendChild(sw);
}
}
function setPalette(name) {
state.palette = name;
document.querySelectorAll('.swatch').forEach(s => s.classList.remove('on'));
const idx = Object.keys(PALETTES).indexOf(name);
const swatches = document.querySelectorAll('.swatch');
if (swatches[idx]) swatches[idx].classList.add('on');
scheduleRender();
}
// --- Set type toggle ---
function toggleSetType() {
state.setType = state.setType === 'mandelbrot' ? 'julia' : 'mandelbrot';
document.getElementById('btnMB').classList.toggle('active', state.setType === 'mandelbrot');
document.getElementById('btnJulia').classList.toggle('active', state.setType === 'julia');
pushHistory();
scheduleRender();
renderJuliaPreview();
updateHUD();
saveStateToHash();
}
document.getElementById('btnMB').addEventListener('click', () => { if (state.setType !== 'mandelbrot') toggleSetType(); });
document.getElementById('btnJulia').addEventListener('click', () => { if (state.setType !== 'julia') toggleSetType(); });
// --- Iteration slider ---
const iterSlider = document.getElementById('iterSlider');
const iterVal = document.getElementById('iterVal');
iterSlider.addEventListener('input', () => {
state.maxIter = parseInt(iterSlider.value);
state.manualIter = true;
iterVal.textContent = state.maxIter;
scheduleRender();
});
iterSlider.addEventListener('change', () => {
// Keep manual mode on change
});
// --- Cycle slider ---
const cycleSlider = document.getElementById('cycleSlider');
cycleSlider.addEventListener('input', () => {
state.paletteCycle = parseInt(cycleSlider.value);
// Colorize-only pass — no recomputation of iterations
scheduleRender();
});
// --- Navigation buttons ---
document.getElementById('btnBack').addEventListener('click', zoomBack);
document.getElementById('btnReset').addEventListener('click', resetView);
document.getElementById('btnSave').addEventListener('click', saveBookmark);
document.getElementById('btnLoad').addEventListener('click', loadBookmark);
function zoomBack() {
if (state.histIndex > 0) {
state.histIndex--;
const entry = state.history[state.histIndex];
state.view.re = entry.re;
state.view.im = entry.im;
state.view.scale = entry.scale;
state.setType = entry.setType;
state.juliaC = { ...entry.juliaC };
state.palette = entry.palette;
state.paletteCycle = entry.paletteCycle;
state.maxIter = entry.maxIter;
state.manualIter = entry.manualIter;
updateUIFromState();
scheduleRender();
renderJuliaPreview();
updateHUD();
saveStateToHash();
}
}
function resetView() {
state.view = { re: -0.5, im: 0, scale: 2.5 };
state.setType = 'mandelbrot';
state.juliaC = { re: -0.7, im: 0.27015 };
state.palette = 'fire';
state.paletteCycle = 0;
state.maxIter = 200;
state.manualIter = false;
state.history = [];
state.histIndex = -1;
iterSlider.value = 200;
iterVal.textContent = '200';
cycleSlider.value = 0;
updateUIFromState();
pushHistory();
scheduleRender();
renderJuliaPreview();
updateHUD();
saveStateToHash();
}
function updateUIFromState() {
document.getElementById('btnMB').classList.toggle('active', state.setType === 'mandelbrot');
document.getElementById('btnJulia').classList.toggle('active', state.setType === 'julia');
iterSlider.value = state.maxIter;
iterVal.textContent = state.maxIter;
cycleSlider.value = state.paletteCycle;
setPalette(state.palette);
}
// =============================================
// BOOKMARKS / LOCAL STORAGE
// =============================================
function saveBookmark() {
const bookmarks = JSON.parse(localStorage.getItem('fractalBookmarks') || '[]');
const entry = {
name: 'Bookmark ' + (bookmarks.length + 1),
re: state.view.re,
im: state.view.im,
scale: state.view.scale,
setType: state.setType,
juliaC: { ...state.juliaC },
palette: state.palette,
paletteCycle: state.paletteCycle,
maxIter: state.maxIter,
manualIter: state.manualIter,
time: Date.now(),
};
bookmarks.push(entry);
localStorage.setItem('fractalBookmarks', JSON.stringify(bookmarks));
showToast('Saved: ' + entry.name);
}
function loadBookmark() {
const bookmarks = JSON.parse(localStorage.getItem('fractalBookmarks') || '[]');
if (bookmarks.length === 0) { showToast('No bookmarks found'); return; }
const entry = bookmarks[bookmarks.length - 1]; // Load most recent
state.view.re = entry.re;
state.view.im = entry.im;
state.view.scale = entry.scale;
state.setType = entry.setType;
state.juliaC = { ...entry.juliaC };
state.palette = entry.palette;
state.paletteCycle = entry.paletteCycle;
state.maxIter = entry.maxIter;
state.manualIter = entry.manualIter;
updateUIFromState();
pushHistory();
scheduleRender();
renderJuliaPreview();
updateHUD();
saveStateToHash();
showToast('Loaded: ' + entry.name);
}
function showToast(msg) {
const tt = document.getElementById('tooltip');
tt.textContent = msg;
tt.style.display = 'block';
tt.style.left = '50%';
tt.style.top = '60px';
tt.style.transform = 'translateX(-50%)';
clearTimeout(tt._timer);
tt._timer = setTimeout(() => { tt.style.display = 'none'; }, 2000);
}
// =============================================
// URL HASH STATE
// =============================================
function saveStateToHash() {
const v = state.view;
const hash = `#re=${v.re.toExponential(6)}&im=${v.im.toExponential(6)}&scale=${v.scale.toExponential(6)}&type=${state.setType}&cr=${state.juliaC.re.toExponential(6)}&ci=${state.juliaC.im.toExponential(6)}&pal=${state.palette}&cyc=${state.paletteCycle}&iter=${state.maxIter}`;
history.replaceState(null, '', hash);
}
function loadStateFromHash() {
const hash = window.location.hash;
if (!hash || hash.length < 2) return false;
try {
const params = new URLSearchParams(hash.slice(1));
if (params.has('re')) {
state.view.re = parseFloat(params.get('re'));
state.view.im = parseFloat(params.get('im'));
state.view.scale = parseFloat(params.get('scale'));
state.setType = params.get('type') || 'mandelbrot';
state.juliaC.re = parseFloat(params.get('cr'));
state.juliaC.im = parseFloat(params.get('ci'));
state.palette = params.get('pal') || 'fire';
state.paletteCycle = parseInt(params.get('cyc')) || 0;
state.maxIter = parseInt(params.get('iter')) || 200;
state.manualIter = params.has('iter');
return true;
}
} catch (e) { /* ignore */ }
return false;
}
// =============================================
// FPS COUNTER
// =============================================
function updateFPS() {
state.frameCount++;
const now = performance.now();
if (now - state.lastFpsTime >= 1000) {
state.fps = state.frameCount;
state.frameCount = 0;
state.lastFpsTime = now;
updateHUD();
}
}
// =============================================
// DEBUG INTERFACE (window.fractal)
// =============================================
window.fractal = {
/** Get iteration count at a given complex point (for Mandelbrot: c = re+imi; for Julia: z0 = re+imi) */
iterationsAt(re, im) {
const maxIter = getAdaptiveMaxIter(getMagnification());
if (state.setType === 'julia') {
return juliaIter(re, im, state.juliaC.re, state.juliaC.im, maxIter);
}
return mandelbrotIter(re, im, maxIter);
},
/** Set the view center and scale */
setView({ re, im, scale }) {
applyView({ re, im, scale: Math.max(1e-15, scale) });
},
/** Get current view state */
getView() { return { ...state.view }; },
/** Get current magnification */
getMagnification() { return getMagnification(); },
/** Get current max iterations */
getMaxIter() { return getAdaptiveMaxIter(getMagnification()); },
};
// =============================================
// INITIALIZATION
// =============================================
function init() {
resizeCanvas();
buildSwatches();
// Load state from URL hash if present
const hashLoaded = loadStateFromHash();
if (hashLoaded) {
updateUIFromState();
} else {
pushHistory(); // Initial state in history
}
renderJuliaPreview();
scheduleRender();
updateHUD();
showHUD();
// HUD auto-hide timer
fractalCanvas.addEventListener('mousemove', () => showHUD());
fractalCanvas.addEventListener('mousedown', () => showHUD());
fractalCanvas.addEventListener('wheel', () => showHUD());
// Start FPS counter loop
(function fpsLoop() {
updateFPS();
requestAnimationFrame(fpsLoop);
})();
}
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"inclusionai/ling-3.0-flash","provider":"openrouter","persona":"full","sessionId":"7d78b1df-c56b-4d1b-83aa-5bd6d0bfeb63","tokensIn":1124151,"tokensOut":61781,"tokensTotal":1185932,"cost":0.0142867326,"turns":19,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-08-24T22:45:04.690Z"} -->