← Fractal Explorer results

Fractal Explorer

ling 3.0 Flash · full

Initial view of Fractal Explorer

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"} -->