← Fractal Explorer results

Fractal Explorer

muse spark 1.2 contributor · full

Initial view of Fractal Explorer

Browser check: Artifact runtime error. This is a technical check, not a quality review.

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 47,700 bytes · SHA-256 242991271293
<!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}
  :root{--glass:rgba(18,18,24,0.72);--glass2:rgba(28,28,38,0.55);--bdr:rgba(255,255,255,0.12);--accent:#7c5cff;--text:#e8e8ef;--muted:#9aa0b2}
  html,body{width:100%;height:100%;overflow:hidden;background:#06070a;color:var(--text);font-family: ui-sans-system, -apple-system, Segoe UI, Roboto, Inter, sans-serif}
  #wrap{position:relative;width:100%;height:100vh;overflow:hidden}
  canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
  #mainCanvas{z-index:1;cursor:crosshair}
  #overlayCanvas{z-index:2;pointer-events:none}
  /* HUD */
  #hud{position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:10;display:flex;gap:10px;align-items:center;
    background:#1a1a24;border:1px solid rgba(255,255,255,0.18);border-radius:999px;padding:8px 14px 8px 16px;
    box-shadow:0 8px 32px rgba(0,0,0,0.65);transition:opacity .4s, transform .4s;white-space:nowrap}
  #hud.idle{opacity:0.92;transform:translateX(-50%) translateY(-2px)}
  #hud:hover{opacity:1}
  .hud-sep{width:1px;height:22px;background:var(--bdr);margin:0 2px}
  .hud-item{display:flex;flex-direction:column;line-height:1}
  .hud-label{font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);font-weight:700}
  .hud-value{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
  .hud-value small{font-weight:400;color:var(--muted)}
  #fps{color:#7CFF9A}
  /* Left Panel */
  #panel{position:absolute;left:14px;top:14px;bottom:14px;z-index:9;width:300px;display:flex;flex-direction:column;gap:12px;pointer-events:none}
  #panel > *{pointer-events:auto}
  .glass{background:var(--glass);backdrop-filter:blur(16px) saturate(1.35);border:1px solid var(--bdr);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
  #titleCard{padding:16px 16px 14px}
  #titleCard h1{font-size:15px;letter-spacing:-.02em;display:flex;align-items:center;gap:8px}
  #titleCard h1 i{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#7c5cff,#ff5c9a);display:grid;place-items:center;font-style:normal;font-size:13px}
  #titleCard p{font-size:11.5px;color:var(--muted);margin-top:6px;line-height:1.45}
  .panel-section{padding:14px 14px 12px}
  .panel-section h3{font-size:10px;letter-spacing:.11em;text-transform:uppercase;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;justify-content:space-between}
  .chips{display:flex;gap:6px;flex-wrap:wrap}
  .chip{flex:1 1 calc(50% - 6px);min-width:0;padding:8px 6px;border-radius:10px;border:1px solid var(--bdr);background:rgba(255,255,255,0.06);cursor:pointer;display:flex;align-items:center;gap:8px;transition:.15s}
  .chip.active{border-color:var(--accent);background:rgba(124,92,255,0.18);box-shadow:0 0 0 3px rgba(124,92,255,0.15)}
  .chip:hover{transform:translateY(-1px);background:rgba(255,255,255,0.09)}
  .swatch{width:28px;height:18px;border-radius:6px;border:1px solid rgba(255,255,255,0.18);flex-shrink:0}
  .chip span{font-size:12px;font-weight:600;white-space:nowrap}
  .row{display:flex;gap:8px;align-items:center}
  .btn{appearance:none;border:1px solid var(--bdr);background:rgba(255,255,255,0.08);color:var(--text);padding:7px 12px;border-radius:999px;font-size:12px;font-weight:600;cursor:pointer;transition:.15s;display:inline-flex;align-items:center;gap:6px;justify-content:center}
  .btn:hover{background:rgba(255,255,255,0.14);transform:translateY(-1px)}
  .btn.primary{background:var(--accent);border-color:transparent;color:white;box-shadow:0 6px 18px rgba(124,92,255,0.35)}
  .btn.primary:hover{background:#6e4df0}
  .btn.small{padding:5px 10px;font-size:11px}
  .btn:disabled{opacity:.45;pointer-events:none}
  .slider-row{display:flex;align-items:center;gap:10px;margin:8px 0}
  .slider-row label{font-size:11px;color:var(--muted);min-width:72px}
  .slider-row input[type=range]{flex:1;accent-color:var(--accent);height:4px}
  .slider-row output{font-size:11px;font-variant-numeric:tabular-nums;min-width:44px;text-align:right;color:var(--text);font-weight:600}
  .toggle{display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;user-select:none}
  .toggle input{accent-color:var(--accent)}
  .coords{font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size:11px;color:var(--muted);line-height:1.6;word-break:break-all}
  .coords b{color:var(--text)}
  /* Julia preview */
  #juliaPreview{position:absolute;right:14px;top:14px;z-index:9;width:220px;background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--bdr);border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.4);display:none}
  #juliaPreview.visible{display:block}
  #juliaPreview canvas{position:relative;width:100%;height:220px;display:block}
  #juliaPreview header{padding:10px 12px 8px;display:flex;align-items:center;justify-content:space-between}
  #juliaPreview header h4{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
  #juliaPreview header button{font-size:10px}
  #juliaPreview .meta{padding:6px 12px 10px;font-size:11px;color:var(--muted);font-family:ui-monospace,monospace}
  /* Bookmarks */
  #bookmarks{max-height:140px;overflow:auto;display:flex;flex-direction:column;gap:6px}
  .bm{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:10px;background:rgba(255,255,255,0.06);border:1px solid transparent}
  .bm:hover{border-color:var(--bdr);background:rgba(255,255,255,0.09)}
  .bm-name{flex:1;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .bm-meta{font-size:10px;color:var(--muted)}
  /* Bottom hint */
  #hint{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);z-index:9;background:var(--glass);backdrop-filter:blur(12px);border:1px solid var(--bdr);border-radius:999px;padding:7px 14px;font-size:11px;color:var(--muted);display:flex;gap:14px;align-items:center;box-shadow:0 8px 32px rgba(0,0,0,0.35)}
  #hint kbd{background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.15);border-bottom-width:2px;padding:1px 5px;border-radius:5px;font-family:ui-monospace,monospace;font-size:10px;color:var(--text)}
  /* Mobile */
  @media (max-width: 860px){
    #panel{width:260px;top:64px}
    #hud{top:8px;padding:6px 10px;gap:6px}
    .hud-label{font-size:8px}
    .hud-value{font-size:11px}
    #juliaPreview{width:160px;right:8px;top:60px}
    #juliaPreview canvas{height:160px}
    #hint{display:none}
  }
  /* Scrollbar */
  ::-webkit-scrollbar{width:6px;height:6px}
  ::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15);border-radius:999px}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="mainCanvas"></canvas>
  <canvas id="overlayCanvas"></canvas>

  <div id="hud">
    <div class="hud-item"><span class="hud-label">Magnification</span><span class="hud-value" id="hudZoom">×1.0</span></div>
    <div class="hud-sep"></div>
    <div class="hud-item"><span class="hud-label">Center</span><span class="hud-value" id="hudCenter" style="font-family:ui-monospace,monospace;font-size:11px">-0.50 + 0.00i</span></div>
    <div class="hud-sep"></div>
    <div class="hud-item"><span class="hud-label">Iterations</span><span class="hud-value" id="hudIters">256</span></div>
    <div class="hud-sep"></div>
    <div class="hud-item"><span class="hud-label">FPS</span><span class="hud-value" id="fps">—</span></div>
    <div class="hud-sep"></div>
    <div class="hud-item"><span class="hud-label">Mode</span><span class="hud-value" id="hudMode" style="font-size:11px">Mandelbrot</span></div>
  </div>

  <div id="panel">
    <div class="glass" id="titleCard">
      <h1><i>◈</i> Fractal Explorer</h1>
      <p>Deep-zoom Mandelbrot & Julia sets with smooth coloring. Drag a rectangle to zoom, scroll to zoom toward cursor, drag with <b style="color:var(--text)">right-button</b> or <b style="color:var(--text)">Space</b> to pan.</p>
      <div class="row" style="margin-top:10px">
        <button class="btn small" id="btnBack" title="Back (previous view)">↩ Back</button>
        <button class="btn small" id="btnReset" title="Reset view">⟲ Reset</button>
        <button class="btn small primary" id="btnJuliaToggle" title="Press J">Julia: Off</button>
      </div>
    </div>

    <div class="glass panel-section">
      <h3>Palette <span id="paletteName" style="text-transform:none;letter-spacing:0;color:var(--text);font-size:11px">Inferno</span></h3>
      <div class="chips" id="paletteChips"></div>
      <div class="slider-row"><label>Cycle</label><input type="range" id="cycleSlider" min="0" max="100" value="0"><output id="cycleOut">0%</output></div>
      <label class="toggle"><input type="checkbox" id="smoothToggle" checked> Smooth coloring</label>
      <label class="toggle" style="margin-top:6px"><input type="checkbox" id="previewToggle" checked> Live Julia preview on hover</label>
    </div>

    <div class="glass panel-section">
      <h3>Render <span id="depthWarn" style="color:#ff6b6b;display:none;font-size:10px">⚠ Max depth</span></h3>
      <div class="slider-row"><label>Iterations</label><input type="range" id="iterSlider" min="64" max="2048" step="32" value="256"><output id="iterOut">256</output></div>
      <label class="toggle"><input type="checkbox" id="autoIterToggle" checked> Auto-scale with zoom</label>
      <div class="slider-row"><label>Julia Re</label><input type="range" id="juliaRe" min="-2" max="2" step="0.001" value="-0.8"><output id="juliaReOut">-0.800</output></div>
      <div class="slider-row"><label>Julia Im</label><input type="range" id="juliaIm" min="-2" max="2" step="0.001" value="0.156"><output id="juliaImOut">0.156</output></div>
      <div class="row" style="margin-top:8px">
        <button class="btn small" id="btnSeahorse">★ Seahorse Valley</button>
        <button class="btn small" id="btnElephant">★ Elephant</button>
      </div>
    </div>

    <div class="glass panel-section">
      <h3>Bookmarks <button class="btn small" id="btnSaveBm" style="padding:3px 8px">+ Save</button></h3>
      <div id="bookmarks"></div>
      <div class="row" style="margin-top:8px">
        <button class="btn small" id="btnShare">Copy Link</button>
        <button class="btn small" id="btnClearBm">Clear all</button>
      </div>
      <div class="coords" style="margin-top:10px" id="coordReadout"></div>
    </div>
  </div>

  <div id="juliaPreview">
    <header><h4>Julia Preview</h4><button class="btn small" id="btnEnterJulia">Enter →</button></header>
    <canvas id="previewCanvas" width="220" height="220"></canvas>
    <div class="meta" id="previewMeta">c = -0.800 + 0.156i</div>
  </div>

  <div id="hint">
    <span><kbd>Drag</kbd> rectangle zoom</span>
    <span><kbd>Scroll</kbd> zoom to cursor</span>
    <span><kbd>Right-drag</kbd> pan</span>
    <span><kbd>J</kbd> Julia</span>
    <span><kbd>H</kbd> hide UI</span>
    <span><kbd>←→↑↓</kbd> pan</span>
    <span><kbd>+</kbd><kbd>−</kbd> zoom</span>
  </div>
</div>

<script>
// ============================================================
// TUNABLE CONSTANTS — edit these to tune behavior
// ============================================================
const CONFIG = {
  // view
  initialCenter: { re: -0.5, im: 0 },
  initialScale: null, // computed from canvas width (complex units per pixel)
  maxMagnification: 1e13, // ~ double precision limit
  minScale: 1e-13, // derived from maxMag
  zoomSpeed: 0.12, // wheel zoom factor per notch (0.12 = ~12% per tick)
  // iterations
  baseIterations: 256,
  iterScaleFactor: 0.18, // log2(mag) * factor added to base
  maxIterationsCap: 2048,
  minIterations: 64,
  // rendering
  progressive: true,
  progressiveSteps: [4, 2, 1], // coarse to fine
  chunkRows: 32, // rows per yield to keep UI responsive
  useWorkers: false, // set true to enable Blob workers (simple tile workers)
  workerCount: 4,
  // palettes
  palettes: {
    inferno: { name:'Inferno', stops:[
      {t:0, c:[10,5,40]}, {t:0.18,c:[60,20,120]}, {t:0.32,c:[140,30,160]}, {t:0.48,c:[210,50,110]}, {t:0.62,c:[255,110,40]}, {t:0.76,c:[255,190,50]}, {t:0.88,c:[255,230,120]}, {t:1,c:[255,255,220]}
    ]},
    ocean: { name:'Ocean', stops:[
      {t:0,c:[2,8,30]}, {t:0.2,c:[4,30,80]}, {t:0.4,c:[10,90,140]}, {t:0.6,c:[30,160,180]}, {t:0.8,c:[120,220,220]}, {t:1,c:[240,255,255]}
    ]},
    monochrome: { name:'Monochrome', stops:[
      {t:0,c:[8,8,10]}, {t:0.25,c:[40,40,45]}, {t:0.5,c:[110,110,115]}, {t:0.75,c:[190,190,195]}, {t:1,c:[255,255,255]}
    ]},
    psychedelic: { name:'Psychedelic', stops:[
      {t:0,c:[255,20,80]}, {t:0.17,c:[255,180,0]}, {t:0.33,c:[40,255,120]}, {t:0.5,c:[30,140,255]}, {t:0.67,c:[140,40,255]}, {t:0.83,c:[255,40,180]}, {t:1,c:[255,20,80]}
    ]},
    ember: { name:'Ember', stops:[
      {t:0,c:[10,0,0]}, {t:0.3,c:[120,20,0]}, {t:0.55,c:[220,80,0]}, {t:0.75,c:[255,180,40]}, {t:1,c:[255,255,200]}
    ]}
  },
  defaultPalette: 'inferno',
  // seahorse valley etc
  presets: {
    seahorse: { re: -0.743643887037151, im: 0.13182590420533, scale: 0.000004 },
    elephant: { re: 0.274226, im: 0.00742, scale: 0.000008 },
    cusp: { re: 0.25, im: 0, scale: 0.002 }
  }
};

// ============================================================
// STATE
// ============================================================
const els = {};
let canvas, ctx, overlay, octx, previewCanvas, pctx;
let W=0,H=0,dpr=1;
let view = { re: CONFIG.initialCenter.re, im: CONFIG.initialCenter.im, scale: 0.0035 }; // complex units per pixel
let historyStack = [];
let renderId = 0;
let isJulia = false;
let juliaC = { re: -0.8, im: 0.156 };
let hoverC = { re: -0.8, im: 0.156 };
let paletteKey = CONFIG.defaultPalette;
let paletteOffset = 0; // 0..1
let smoothColor = true;
let autoIter = true;
let manualIter = CONFIG.baseIterations;
let animFrame = 0;
let lastFpsTime = performance.now(), frames=0, fpsVal=0;
let hudIdleTimer=null;
let showUI=true;
let isDragging=false, dragStart=null, dragCurrent=null, isPanning=false, panStart=null, panViewStart=null;
let spaceHeld=false;
let previewEnabled=true;

// buffers
let iterBuffer = null; // Float32Array W*H
let imageData = null;

// ============================================================
// MATH — allocation-free iteration
// ============================================================
function mandelbrotIter(cr, ci, maxIter){
  let zr=0, zi=0, zr2=0, zi2=0;
  let iter=0;
  // cardioid/bulb early exit optimization (optional but speeds interior)
  // main cardioid check
  let q = (cr - 0.25)*(cr - 0.25) + ci*ci;
  if (q*(q + (cr - 0.25)) < 0.25*ci*ci) return maxIter;
  // period-2 bulb
  if ((cr+1)*(cr+1) + ci*ci < 0.0625) return maxIter;
  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;
  if (!smoothColor) return iter;
  // smooth coloring: mu = n + 1 - log2(log|z|)
  // |z| = sqrt(zr2+zi2)
  let log_zn = Math.log(zr2 + zi2) * 0.5;
  if (log_zn <= 0) return iter;
  let nu = Math.log(log_zn / Math.log(2)) / Math.log(2);
  let mu = iter + 1 - nu;
  if (mu < 0) mu = iter;
  if (mu > maxIter) mu = maxIter;
  return mu;
}
function juliaIter(zr0, zi0, cr, ci, maxIter){
  let zr=zr0, zi=zi0;
  let zr2=zr*zr, zi2=zi*zi;
  let iter=0;
  while (iter < maxIter && (zr2+zi2) <= 4){
    let nzr = zr2 - zi2 + cr;
    let nzi = 2*zr*zi + ci;
    zr=nzr; zi=nzi;
    zr2=zr*zr; zi2=zi*zi;
    iter++;
  }
  if (iter===maxIter) return maxIter;
  if (!smoothColor) return iter;
  let log_zn = Math.log(zr2+zi2)*0.5;
  if (log_zn <=0) return iter;
  let nu = Math.log(log_zn/Math.log(2))/Math.log(2);
  let mu = iter + 1 - nu;
  return mu;
}
function iterationsAt(re, im){
  const maxI = getCurrentMaxIter();
  return mandelbrotIter(re, im, maxI);
}
function juliaIterationsAt(re, im){
  const maxI = getCurrentMaxIter();
  return juliaIter(re, im, juliaC.re, juliaC.im, maxI);
}

// ============================================================
// VIEW HELPERS
// ============================================================
function getCurrentMaxIter(){
  if (!autoIter) return manualIter;
  const initialScale = CONFIG.initialScale || 0.0035;
  const mag = initialScale / view.scale;
  const extra = Math.log2(Math.max(1, mag)) * 18; // ~18 per doubling
  let it = CONFIG.baseIterations + extra;
  it = Math.max(CONFIG.minIterations, Math.min(CONFIG.maxIterationsCap, Math.round(it)));
  return it;
}
function magnification(){
  const init = CONFIG.initialScale || 0.0035;
  return init / view.scale;
}
function pixelToComplex(px, py){
  // px,py in canvas CSS pixels (not device pixels) — we use W,H as device pixels, but view.scale is per device pixel?
  // Let's define view.scale as complex units per CSS pixel? Simpler per device pixel? We'll use per CSS pixel for intuitive.
  // Actually we set W = canvas.width (device pixels), but we want mapping consistent. Use CSS size.
  const cssW = canvas.clientWidth, cssH = canvas.clientHeight;
  const re = view.re + (px - cssW/2) * view.scale;
  const im = view.im - (py - cssH/2) * view.scale; // y inverted
  return {re, im};
}
function complexToPixel(re, im){
  const cssW = canvas.clientWidth, cssH = canvas.clientHeight;
  const px = (re - view.re)/view.scale + cssW/2;
  const py = (view.im - im)/view.scale + cssH/2;
  return {x:px, y:py};
}
function setView(o){
  if (o.re!==undefined) view.re=o.re;
  if (o.im!==undefined) view.im=o.im;
  if (o.scale!==undefined) view.scale=o.scale;
  if (o.juliaRe!==undefined) juliaC.re=o.juliaRe;
  if (o.juliaIm!==undefined) juliaC.im=o.juliaIm;
  if (o.palette) paletteKey=o.palette;
  if (o.paletteOffset!==undefined) paletteOffset=o.paletteOffset;
  clampView();
  scheduleRender();
  updateHUD();
  updateHash();
}
function getView(){
  return { re: view.re, im: view.im, scale: view.scale, magnification: magnification(), maxIter:getCurrentMaxIter(), palette:paletteKey, paletteOffset, isJulia, juliaC:{...juliaC} };
}
function clampView(){
  const mag = magnification();
  if (mag > CONFIG.maxMagnification){
    view.scale = (CONFIG.initialScale||0.0035)/CONFIG.maxMagnification;
  }
}
function pushHistory(){
  historyStack.push({re:view.re, im:view.im, scale:view.scale});
  if (historyStack.length>64) historyStack.shift();
  updateBackButton();
}
function popHistory(){
  if (!historyStack.length) return;
  const prev = historyStack.pop();
  view.re=prev.re; view.im=prev.im; view.scale=prev.scale;
  updateBackButton();
  scheduleRender(); updateHUD();
}
function resetView(){
  pushHistory();
  view.re=CONFIG.initialCenter.re; view.im=CONFIG.initialCenter.im;
  view.scale=CONFIG.initialScale||0.0035;
  scheduleRender(); updateHUD();
}

// ============================================================
// COLORING
// ============================================================
function buildPaletteLUT(size=1024){
  const pal = CONFIG.palettes[paletteKey];
  if (!pal) return null;
  const lut = new Uint8Array(size*3);
  const stops = pal.stops;
  for(let i=0;i<size;i++){
    let t = (i/size + paletteOffset) % 1;
    // find segment
    let seg=0;
    for(let s=0;s<stops.length-1;s++){
      if (t >= stops[s].t && t <= stops[s+1].t){ seg=s; break; }
      if (s===stops.length-2) seg=s;
    }
    const a=stops[seg], b=stops[seg+1];
    const span = b.t - a.t || 1;
    let f = (t - a.t)/span;
    // handle wrap for last segment
    if (t < a.t || t > b.t){
      // for psychedelic wrap, interpolate across boundary
      // find correct by brute
      // fallback linear search for t
      for(let s=0;s<stops.length-1;s++){
        if (t>=stops[s].t && t<=stops[s+1].t){ seg=s; break; }
      }
    }
    // smoothstep for nicer
    // f = f*f*(3-2*f);
    const r = Math.round(a.c[0] + (b.c[0]-a.c[0])*f);
    const g = Math.round(a.c[1] + (b.c[1]-a.c[1])*f);
    const b2 = Math.round(a.c[2] + (b.c[2]-a.c[2])*f);
    lut[i*3]=r; lut[i*3+1]=g; lut[i*3+2]=b2;
  }
  return lut;
}
let paletteLUT=null, lutSize=1024;
function updateLUT(){ paletteLUT = buildPaletteLUT(lutSize); }

// ============================================================
// RENDERING PIPELINE — progressive, chunked, cancelable
// ============================================================
function ensureBuffers(){
  const w = canvas.width, h = canvas.height;
  if (!iterBuffer || iterBuffer.length !== w*h){
    iterBuffer = new Float32Array(w*h);
    imageData = ctx.createImageData(w,h);
  }
}
function colorizeAndBlit(maxIter){
  const w=canvas.width, h=canvas.height;
  const data = imageData.data;
  const lut = paletteLUT;
  // interior = black
  for(let i=0;i<w*h;i++){
    const mu = iterBuffer[i];
    const idx=i*4;
    if (mu >= maxIter - 0.5){
      data[idx]=0; data[idx+1]=0; data[idx+2]=0; data[idx+3]=255;
    } else {
      let t;
      if (paletteKey==='psychedelic'){
        t = (mu * 0.09 + paletteOffset) % 1;
      } else {
        // ensure μ≈2 maps to ~40% of palette (bright mid-tones)
        let norm = mu / maxIter;
        // strong lift for low μ: linear blend between sqrt and log
        norm = 0.65 * Math.pow(norm, 0.32) + 0.35 * (Math.log(1+mu*4)/Math.log(1+maxIter*4));
        t = (norm*0.68 + 0.30 + paletteOffset) % 1;
      }
      if (t<0) t+=1;
      let li = Math.floor(t*(lutSize-1));
      li = Math.max(0, Math.min(lutSize-1, li));
      data[idx]=lut[li*3];
      data[idx+1]=lut[li*3+1];
      data[idx+2]=lut[li*3+2];
      data[idx+3]=255;
    }
  }
  ctx.putImageData(imageData,0,0);
}

async function renderProgressive(myId){
  const w=canvas.width, h=canvas.height;
  const cssW=canvas.clientWidth, cssH=canvas.clientHeight;
  const maxIter=getCurrentMaxIter();
  ensureBuffers();
  updateLUT();
  // progressive steps
  const steps = CONFIG.progressive ? CONFIG.progressiveSteps : [1];
  for(let s=0;s<steps.length;s++){
    if (myId !== renderId) return; // canceled
    const stride = steps[s];
    // compute iterations at stride
    // we compute every pixel but skip? Actually compute all but for coarse we compute sparse and fill.
    // For stride>1, compute on grid and nearest-neighbor fill for speed
    await computeIterationsChunked(myId, stride, maxIter);
    if (myId !== renderId) return;
    // for stride>1, fill gaps by nearest sampled value (so preview looks blocky but fast)
    if (stride>1){
      // fill missing pixels by copying nearest sampled
      for(let y=0;y<h;y++){
        for(let x=0;x<w;x++){
          if (x%stride===0 && y%stride===0) continue;
          const sx = Math.floor(x/stride)*stride;
          const sy = Math.floor(y/stride)*stride;
          const si = sy*w + sx;
          const di = y*w + x;
          iterBuffer[di]=iterBuffer[si];
        }
      }
    }
    colorizeAndBlit(maxIter);
    updateHUD();
    // small yield to allow UI
    await new Promise(r=> requestAnimationFrame(r));
  }
  // final FPS tick
}

function computeIterationsChunked(myId, stride, maxIter){
  return new Promise(resolve=>{
    const w=canvas.width, h=canvas.height;
    const cssW=canvas.clientWidth, cssH=canvas.clientHeight;
    // view.scale is per CSS pixel, so per device pixel = scale / dpr
    const scalePerDevice = view.scale / (dpr||1);
    const re0 = view.re - (cssW/2)*view.scale;
    const im0 = view.im + (cssH/2)*view.scale; // top
    let y=0;
    function chunk(){
      if (myId !== renderId) { resolve(); return; }
      const yEnd = Math.min(h, y + CONFIG.chunkRows);
      for(let py=y; py<yEnd; py++){
        if (stride>1 && py%stride!==0) continue;
        const im = im0 - py*scalePerDevice;
        for(let px=0; px<w; px++){
          if (stride>1 && px%stride!==0) continue;
          const re = re0 + px*scalePerDevice;
          let mu;
          if (isJulia){
            // Julia: z0 = pixel, c = juliaC
            mu = juliaIter(re, im, juliaC.re, juliaC.im, maxIter);
          } else {
            mu = mandelbrotIter(re, im, maxIter);
          }
          iterBuffer[py*w + px]=mu;
        }
      }
      y=yEnd;
      if (y < h){
        // yield
        setTimeout(chunk, 0);
      } else {
        resolve();
      }
    }
    chunk();
  });
}

let renderQueued=false;
function scheduleRender(){
  renderId++;
  const myId=renderId;
  // debounce slightly for wheel
  if (renderQueued) return;
  renderQueued=true;
  requestAnimationFrame(()=>{
    renderQueued=false;
    renderProgressive(myId);
  });
}

// ============================================================
// OVERLAY — selection rectangle
// ============================================================
function drawOverlay(){
  const cw = overlay.width, ch = overlay.height;
  octx.clearRect(0,0,cw,ch);
  if (isDragging && dragStart && dragCurrent && !isPanning){
    const x = Math.min(dragStart.x, dragCurrent.x);
    const y = Math.min(dragStart.y, dragCurrent.y);
    const w = Math.abs(dragCurrent.x - dragStart.x);
    const h = Math.abs(dragCurrent.y - dragStart.y);
    // dim outside
    octx.fillStyle='rgba(0,0,0,0.35)';
    octx.fillRect(0,0,cw,ch);
    octx.clearRect(x*dpr,y*dpr,w*dpr,h*dpr);
    // border inverted
    octx.save();
    octx.strokeStyle='white';
    octx.lineWidth=1.5*dpr;
    octx.setLineDash([6*dpr,4*dpr]);
    octx.strokeRect(x*dpr+0.5,y*dpr+0.5,w*dpr-1,h*dpr-1);
    octx.strokeStyle='black';
    octx.setLineDash([6*dpr,4*dpr]);
    octx.lineDashOffset=6*dpr;
    octx.strokeRect(x*dpr+0.5,y*dpr+0.5,w*dpr-1,h*dpr-1);
    octx.restore();
    // info
    const re0 = pixelToComplex(x,y);
    const re1 = pixelToComplex(x+w,y+h);
    const selW = Math.abs(re1.re - re0.re);
    octx.fillStyle='rgba(255,255,255,0.9)';
    octx.font=`${11*dpr}px ui-monospace,monospace`;
    octx.fillText(`${selW.toExponential(2)} units`, (x+6)*dpr, (y+16)*dpr);
  }
  // crosshair for preview mode
  if (!isJulia && previewEnabled && !isDragging){
    // draw subtle crosshair at last hover? Not needed
  }
  // center reticle
  if (!isDragging){
    const cx = overlay.clientWidth/2, cy=overlay.clientHeight/2;
    octx.strokeStyle='rgba(255,255,255,0.35)';
    octx.lineWidth=1;
    octx.beginPath();
    octx.moveTo((cx-10)*dpr, cy*dpr); octx.lineTo((cx+10)*dpr, cy*dpr);
    octx.moveTo(cx*dpr, (cy-10)*dpr); octx.lineTo(cx*dpr, (cy+10)*dpr);
    octx.stroke();
  }
}

// ============================================================
// INTERACTION
// ============================================================
function canvasPos(e){
  const rect=canvas.getBoundingClientRect();
  return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onMouseDown(e){
  if (e.button===2 || e.button===1 || spaceHeld || e.shiftKey){
    // pan
    isPanning=true;
    panStart=canvasPos(e);
    panViewStart={re:view.re, im:view.im};
    e.preventDefault();
    return;
  }
  if (e.button===0){
    isDragging=true;
    dragStart=canvasPos(e);
    dragCurrent={...dragStart};
  }
}
function onMouseMove(e){
  const pos=canvasPos(e);
  // hover Julia preview
  if (!isJulia && previewEnabled && !isDragging && !isPanning){
    const c = pixelToComplex(pos.x, pos.y);
    hoverC.re=c.re; hoverC.im=c.im;
    schedulePreview();
    // update coord readout
    els.coordReadout.innerHTML = `Cursor: <b>${c.re.toFixed(6)}</b> <b>${c.im>0?'+':''}${c.im.toFixed(6)}i</b>`;
  }
  if (isPanning && panStart){
    const dx = pos.x - panStart.x;
    const dy = pos.y - panStart.y;
    view.re = panViewStart.re - dx*view.scale;
    view.im = panViewStart.im + dy*view.scale;
    scheduleRender(); updateHUD(); drawOverlay();
    resetHudIdle();
  } else if (isDragging && dragStart){
    dragCurrent=pos;
    drawOverlay();
  }
}
function onMouseUp(e){
  if (isPanning){
    isPanning=false;
    pushHistory();
    scheduleRender();
    return;
  }
  if (isDragging && dragStart && dragCurrent){
    const x0=Math.min(dragStart.x,dragCurrent.x), x1=Math.max(dragStart.x,dragCurrent.x);
    const y0=Math.min(dragStart.y,dragCurrent.y), y1=Math.max(dragStart.y,dragCurrent.y);
    const w=x1-x0, h=y1-y0;
    isDragging=false; dragStart=null; dragCurrent=null;
    drawOverlay();
    if (w<10 || h<10){
      // click — if Julia mode off, click to set Julia c? spec says click any point to open Julia
      // We'll treat small drag as click: set Julia c and optionally enter Julia mode if shift?
      // For now, just set hover and flash
      const c = pixelToComplex((x0+x1)/2,(y0+y1)/2);
      hoverC.re=c.re; hoverC.im=c.im;
      juliaC.re=c.re; juliaC.im=c.im;
      els.juliaRe.value=juliaC.re; els.juliaIm.value=juliaC.im;
      els.juliaReOut.textContent=juliaC.re.toFixed(3);
      els.juliaImOut.textContent=juliaC.im.toFixed(3);
      schedulePreview();
      // auto-enter Julia if button? we don't auto, but update preview
      return;
    }
    // rectangle zoom — compute new view to exactly match rectangle, preserving aspect
    // We must handle aspect ratio: the selected rectangle may not match canvas aspect.
    // Spec says must land on exactly selected rectangle — so we adjust view to fit rectangle, expanding to match canvas aspect if needed? But "exactly" suggests we should not shift.
    // Approach: compute complex bounds of selection, then set view center to midpoint, and scale to fit width or height whichever is tighter, but ensure selection is fully visible.
    // To be exact, we set scale so that selection width maps to canvas width proportionally? Actually selection w/h may differ from canvas aspect, so we need to expand the smaller dimension.
    const c0 = pixelToComplex(x0,y0);
    const c1 = pixelToComplex(x1,y1);
    // c0 is top-left, c1 bottom-right
    const selCenterRe = (c0.re + c1.re)/2;
    const selCenterIm = (c0.im + c1.im)/2;
    const selW = Math.abs(c1.re - c0.re);
    const selH = Math.abs(c1.im - c0.im);
    const canvasAspect = canvas.clientWidth / canvas.clientHeight;
    const selAspect = selW / selH;
    let newScale;
    if (selAspect > canvasAspect){
      // width determines
      newScale = selW / canvas.clientWidth;
    } else {
      newScale = selH / canvas.clientHeight;
    }
    pushHistory();
    view.re = selCenterRe;
    view.im = selCenterIm;
    view.scale = newScale;
    clampView();
    scheduleRender(); updateHUD();
  }
}
function onWheel(e){
  e.preventDefault();
  const pos=canvasPos(e);
  const cBefore = pixelToComplex(pos.x,pos.y);
  const factor = Math.exp(-e.deltaY * 0.0015); // smooth
  // clamp factor
  const newScale = view.scale / factor;
  // check max mag
  const init = CONFIG.initialScale||0.0035;
  if (init / newScale > CONFIG.maxMagnification && factor>1){
    // block
    els.depthWarn.style.display='inline';
    setTimeout(()=>els.depthWarn.style.display='none',1200);
    return;
  }
  pushHistory();
  view.scale = newScale;
  const cAfter = pixelToComplex(pos.x,pos.y);
  // adjust center so cursor point stays fixed
  view.re += cBefore.re - cAfter.re;
  view.im += cBefore.im - cAfter.im;
  clampView();
  scheduleRender(); updateHUD();
  resetHudIdle();
}

// ============================================================
// PREVIEW JULIA
// ============================================================
let previewRaf=0, previewDirty=false;
function schedulePreview(){
  if (!previewEnabled) return;
  previewDirty=true;
  if (previewRaf) return;
  previewRaf=requestAnimationFrame(()=>{
    previewRaf=0;
    if (!previewDirty) return;
    previewDirty=false;
    renderPreview();
  });
}
function renderPreview(){
  const pc = previewCanvas;
  if (!pc) return;
  const w=pc.width, h=pc.height;
  const ctx2=pctx;
  const maxIter= Math.min(512, getCurrentMaxIter());
  // Julia c = hoverC
  const scale = 3.0 / w; // view width 3 units
  const lut = paletteLUT || buildPaletteLUT(lutSize);
  const img = ctx2.createImageData(w,h);
  const data=img.data;
  for(let y=0;y<h;y++){
    const im = 1.5 - y*scale;
    for(let x=0;x<w;x++){
      const re = -1.5 + x*scale;
      const mu = juliaIter(re, im, hoverC.re, hoverC.im, maxIter);
      const idx=(y*w+x)*4;
      if (mu>=maxIter-0.5){ data[idx]=0; data[idx+1]=0; data[idx+2]=0; data[idx+3]=255; }
      else {
        let t = (mu/maxIter + paletteOffset)%1;
        if (paletteKey==='psychedelic') t=(mu*0.07+paletteOffset)%1;
        let li=Math.floor(t*(lutSize-1));
        data[idx]=lut[li*3]; data[idx+1]=lut[li*3+1]; data[idx+2]=lut[li*3+2]; data[idx+3]=255;
      }
    }
  }
  ctx2.putImageData(img,0,0);
  els.previewMeta.textContent = `c = ${hoverC.re.toFixed(4)} ${hoverC.im>=0?'+':''}${hoverC.im.toFixed(4)}i`;
  document.getElementById('juliaPreview').classList.add('visible');
}

// ============================================================
// HUD / UI
// ============================================================
function updateHUD(){
  const mag = magnification();
  let magStr;
  if (mag < 1000) magStr = `×${mag.toFixed(1)}`;
  else if (mag < 1e6) magStr = `×${(mag/1e3).toFixed(1)}k`;
  else magStr = `×${mag.toExponential(2)}`;
  els.hudZoom.textContent=magStr;
  els.hudCenter.textContent=`${view.re.toFixed(6)} ${view.im>=0?'+':''}${view.im.toFixed(6)}i`;
  els.hudIters.textContent=getCurrentMaxIter();
  els.hudMode.textContent = isJulia ? `Julia c=${juliaC.re.toFixed(2)}${juliaC.im>=0?'+':''}${juliaC.im.toFixed(2)}i` : 'Mandelbrot';
  els.iterOut.textContent=getCurrentMaxIter();
  if (!autoIter) els.iterOut.textContent=manualIter;
  els.coordReadout.innerHTML = `Center: <b>${view.re.toFixed(8)}</b> <b>${view.im>=0?'+':''}${view.im.toFixed(8)}i</b><br>Scale: <b>${view.scale.toExponential(4)}</b> · Mag <b>${mag.toExponential(2)}</b>`;
  // depth warn
  if (mag > CONFIG.maxMagnification*0.8) els.depthWarn.style.display='inline';
  else els.depthWarn.style.display='none';
  updateBackButton();
}
function updateBackButton(){
  els.btnBack.disabled = historyStack.length===0;
}
function resetHudIdle(){
  const hud=document.getElementById('hud');
  hud.classList.remove('idle');
  clearTimeout(hudIdleTimer);
  hudIdleTimer=setTimeout(()=>hud.classList.add('idle'), 2800);
}

// ============================================================
// BOOKMARKS & HASH
// ============================================================
function saveBookmark(name){
  const bm = { name: name||`View ${new Date().toLocaleTimeString()}`, view:{re:view.re,im:view.im,scale:view.scale}, palette:paletteKey, paletteOffset, isJulia, juliaC:{...juliaC}, maxIter:getCurrentMaxIter(), autoIter, manualIter };
  const arr = JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
  arr.push(bm);
  localStorage.setItem('fractalBookmarks', JSON.stringify(arr));
  renderBookmarks();
}
function renderBookmarks(){
  const arr = JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
  const cont=els.bookmarks;
  cont.innerHTML='';
  arr.forEach((bm,i)=>{
    const div=document.createElement('div');
    div.className='bm';
    div.innerHTML=`<div style="flex:1;min-width:0"><div class="bm-name">${bm.name}</div><div class="bm-meta">${bm.view.re.toFixed(4)} ${bm.view.im>=0?'+':''}${bm.view.im.toFixed(4)}i · ×${((CONFIG.initialScale||0.0035)/bm.view.scale).toExponential(1)}</div></div>
      <button class="btn small" data-load="${i}">Load</button><button class="btn small" data-del="${i}">✕</button>`;
    cont.appendChild(div);
  });
  cont.querySelectorAll('[data-load]').forEach(b=> b.addEventListener('click',()=>{
    const idx=parseInt(b.getAttribute('data-load'));
    const arr2=JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
    const bm=arr2[idx];
    if (!bm) return;
    pushHistory();
    view.re=bm.view.re; view.im=bm.view.im; view.scale=bm.view.scale;
    paletteKey=bm.palette||paletteKey; paletteOffset=bm.paletteOffset||0;
    isJulia=!!bm.isJulia; if (bm.juliaC) juliaC={...bm.juliaC};
    autoIter=bm.autoIter!==undefined?bm.autoIter:autoIter;
    manualIter=bm.manualIter||manualIter;
    // update UI
    els.cycleSlider.value=Math.round(paletteOffset*100);
    els.cycleOut.textContent=Math.round(paletteOffset*100)+'%';
    els.autoIterToggle.checked=autoIter;
    els.iterSlider.value=manualIter;
    els.juliaRe.value=juliaC.re; els.juliaIm.value=juliaC.im;
    els.juliaReOut.textContent=juliaC.re.toFixed(3); els.juliaImOut.textContent=juliaC.im.toFixed(3);
    updatePaletteUI();
    updateJuliaToggle();
    scheduleRender(); updateHUD(); updateHash();
  }));
  cont.querySelectorAll('[data-del]').forEach(b=> b.addEventListener('click',()=>{
    const idx=parseInt(b.getAttribute('data-del'));
    const arr2=JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
    arr2.splice(idx,1);
    localStorage.setItem('fractalBookmarks', JSON.stringify(arr2));
    renderBookmarks();
  }));
}
function updateHash(){
  const h = `#re=${view.re}&im=${view.im}&scale=${view.scale}&pal=${paletteKey}&off=${paletteOffset.toFixed(3)}&julia=${isJulia?1:0}&jr=${juliaC.re}&ji=${juliaC.im}`;
  history.replaceState(null,'',h);
}
function loadHash(){
  if (!location.hash) return false;
  try{
    const p=new URLSearchParams(location.hash.slice(1));
    if (p.has('re')) view.re=parseFloat(p.get('re'));
    if (p.has('im')) view.im=parseFloat(p.get('im'));
    if (p.has('scale')) view.scale=parseFloat(p.get('scale'));
    if (p.has('pal') && CONFIG.palettes[p.get('pal')]) paletteKey=p.get('pal');
    if (p.has('off')) paletteOffset=parseFloat(p.get('off'));
    if (p.has('julia')) isJulia=p.get('julia')==='1';
    if (p.has('jr')) juliaC.re=parseFloat(p.get('jr'));
    if (p.has('ji')) juliaC.im=parseFloat(p.get('ji'));
    return true;
  }catch(e){ return false; }
}

// ============================================================
// PALETTE UI
// ============================================================
function updatePaletteUI(){
  document.querySelectorAll('.chip').forEach(c=>{
    c.classList.toggle('active', c.dataset.pal===paletteKey);
  });
  els.paletteName.textContent = CONFIG.palettes[paletteKey]?.name || paletteKey;
}
function buildPaletteChips(){
  const cont=els.paletteChips;
  cont.innerHTML='';
  for(const [key,pal] of Object.entries(CONFIG.palettes)){
    const chip=document.createElement('button');
    chip.className='chip'; chip.dataset.pal=key;
    // swatch gradient
    const grad = pal.stops.map(s=> `rgb(${s.c[0]},${s.c[1]},${s.c[2]}) ${s.t*100}%`).join(',');
    chip.innerHTML=`<span class="swatch" style="background:linear-gradient(90deg,${grad})"></span><span>${pal.name}</span>`;
    chip.addEventListener('click',()=>{
      paletteKey=key; updatePaletteUI(); updateLUT(); scheduleRender(); updateHash();
    });
    cont.appendChild(chip);
  }
  updatePaletteUI();
}
function updateJuliaToggle(){
  els.btnJuliaToggle.textContent = isJulia ? 'Julia: On' : 'Julia: Off';
  els.btnJuliaToggle.classList.toggle('primary', isJulia);
}

// ============================================================
// INIT & RESIZE
// ============================================================
function resize(){
  dpr = Math.min(2, window.devicePixelRatio||1);
  const rect = canvas.getBoundingClientRect();
  const w = Math.round(rect.width * dpr);
  const h = Math.round(rect.height * dpr);
  if (w===0||h===0) return;
  canvas.width=w; canvas.height=h;
  overlay.width=w; overlay.height=h;
  W=w; H=h;
  // reset buffers
  iterBuffer=null; imageData=null;
  if (!CONFIG.initialScale){
    CONFIG.initialScale = 3.5 / rect.width; // so initial view width ~3.5 units
    view.scale = CONFIG.initialScale;
  }
  scheduleRender();
  drawOverlay();
}
function init(){
  canvas=document.getElementById('mainCanvas');
  ctx=canvas.getContext('2d', {alpha:false});
  overlay=document.getElementById('overlayCanvas');
  octx=overlay.getContext('2d');
  previewCanvas=document.getElementById('previewCanvas');
  pctx=previewCanvas.getContext('2d');
  // els
  els.hudZoom=document.getElementById('hudZoom');
  els.hudCenter=document.getElementById('hudCenter');
  els.hudIters=document.getElementById('hudIters');
  els.hudMode=document.getElementById('hudMode');
  els.btnBack=document.getElementById('btnBack');
  els.btnReset=document.getElementById('btnReset');
  els.btnJuliaToggle=document.getElementById('btnJuliaToggle');
  els.paletteChips=document.getElementById('paletteChips');
  els.paletteName=document.getElementById('paletteName');
  els.cycleSlider=document.getElementById('cycleSlider');
  els.cycleOut=document.getElementById('cycleOut');
  els.smoothToggle=document.getElementById('smoothToggle');
  els.previewToggle=document.getElementById('previewToggle');
  els.iterSlider=document.getElementById('iterSlider');
  els.iterOut=document.getElementById('iterOut');
  els.autoIterToggle=document.getElementById('autoIterToggle');
  els.juliaRe=document.getElementById('juliaRe');
  els.juliaIm=document.getElementById('juliaIm');
  els.juliaReOut=document.getElementById('juliaReOut');
  els.juliaImOut=document.getElementById('juliaImOut');
  els.bookmarks=document.getElementById('bookmarks');
  els.coordReadout=document.getElementById('coordReadout');
  els.depthWarn=document.getElementById('depthWarn');
  els.previewMeta=document.getElementById('previewMeta');
  // build UI
  buildPaletteChips();
  renderBookmarks();
  // load hash
  loadHash();
  // resize
  resize();
  window.addEventListener('resize', resize);
  // events
  canvas.addEventListener('mousedown', onMouseDown);
  window.addEventListener('mousemove', onMouseMove);
  window.addEventListener('mouseup', onMouseUp);
  canvas.addEventListener('wheel', onWheel, {passive:false});
  canvas.addEventListener('contextmenu', e=> e.preventDefault());
  overlay.addEventListener('contextmenu', e=> e.preventDefault());
  // keyboard
  window.addEventListener('keydown', e=>{
    if (e.code==='Space') spaceHeld=true;
    if (e.key==='j' || e.key==='J'){ isJulia=!isJulia; updateJuliaToggle(); pushHistory(); scheduleRender(); updateHUD(); updateHash(); }
    if (e.key==='h' || e.key==='H'){ showUI=!showUI; document.getElementById('panel').style.display=showUI?'flex':'none'; document.getElementById('juliaPreview').style.display= showUI && previewEnabled ? 'block':'none'; document.getElementById('hint').style.display= showUI?'flex':'none'; }
    if (e.key==='Escape'){ isDragging=false; dragStart=null; dragCurrent=null; drawOverlay(); }
    // pan arrows
    const panStep = view.scale * 40;
    if (e.key==='ArrowLeft'){ view.re -= panStep; scheduleRender(); updateHUD(); }
    if (e.key==='ArrowRight'){ view.re += panStep; scheduleRender(); updateHUD(); }
    if (e.key==='ArrowUp'){ view.im += panStep; scheduleRender(); updateHUD(); }
    if (e.key==='ArrowDown'){ view.im -= panStep; scheduleRender(); updateHUD(); }
    if (e.key==='+'||e.key==='='){ view.scale *= 0.85; scheduleRender(); updateHUD(); }
    if (e.key==='-'||e.key==='_'){ view.scale *= 1.15; scheduleRender(); updateHUD(); }
  });
  window.addEventListener('keyup', e=>{ if (e.code==='Space') spaceHeld=false; if (e.code==='Space') isPanning=false; });
  // buttons
  els.btnBack.addEventListener('click', popHistory);
  els.btnReset.addEventListener('click', resetView);
  els.btnJuliaToggle.addEventListener('click', ()=>{ isJulia=!isJulia; updateJuliaToggle(); scheduleRender(); updateHUD(); updateHash(); });
  els.cycleSlider.addEventListener('input', e=>{
    paletteOffset = parseInt(e.target.value)/100;
    els.cycleOut.textContent=e.target.value+'%';
    updateLUT(); colorizeAndBlit(getCurrentMaxIter()); updateHash();
  });
  els.smoothToggle.addEventListener('change', e=>{ smoothColor=e.target.checked; scheduleRender(); });
  els.previewToggle.addEventListener('change', e=>{
    previewEnabled=e.target.checked;
    document.getElementById('juliaPreview').classList.toggle('visible', previewEnabled);
    if (!previewEnabled) document.getElementById('juliaPreview').style.display='none';
    else document.getElementById('juliaPreview').style.display='block';
  });
  els.autoIterToggle.addEventListener('change', e=>{ autoIter=e.target.checked; els.iterSlider.disabled=autoIter; scheduleRender(); updateHUD(); });
  els.iterSlider.addEventListener('input', e=>{
    manualIter=parseInt(e.target.value);
    els.iterOut.textContent=manualIter;
    if (!autoIter) scheduleRender();
  });
  els.juliaRe.addEventListener('input', e=>{
    juliaC.re=parseFloat(e.target.value); els.juliaReOut.textContent=juliaC.re.toFixed(3);
    if (isJulia) scheduleRender(); else schedulePreview();
    updateHash();
  });
  els.juliaIm.addEventListener('input', e=>{
    juliaC.im=parseFloat(e.target.value); els.juliaImOut.textContent=juliaC.im.toFixed(3);
    if (isJulia) scheduleRender(); else schedulePreview();
    updateHash();
  });
  document.getElementById('btnSeahorse').addEventListener('click', ()=>{
    pushHistory(); view.re=CONFIG.presets.seahorse.re; view.im=CONFIG.presets.seahorse.im; view.scale=CONFIG.presets.seahorse.scale; scheduleRender(); updateHUD();
  });
  document.getElementById('btnElephant').addEventListener('click', ()=>{
    pushHistory(); view.re=CONFIG.presets.elephant.re; view.im=CONFIG.presets.elephant.im; view.scale=CONFIG.presets.elephant.scale; scheduleRender(); updateHUD();
  });
  document.getElementById('btnEnterJulia').addEventListener('click', ()=>{
    juliaC.re=hoverC.re; juliaC.im=hoverC.im;
    els.juliaRe.value=juliaC.re; els.juliaIm.value=juliaC.im;
    els.juliaReOut.textContent=juliaC.re.toFixed(3); els.juliaImOut.textContent=juliaC.im.toFixed(3);
    isJulia=true; updateJuliaToggle(); scheduleRender(); updateHUD(); updateHash();
  });
  document.getElementById('btnSaveBm').addEventListener('click', ()=>{
    const name=prompt('Bookmark name:', `View ${new Date().toLocaleTimeString()}`);
    if (name!==null) saveBookmark(name||undefined);
  });
  document.getElementById('btnClearBm').addEventListener('click', ()=>{
    if (confirm('Clear all bookmarks?')){ localStorage.removeItem('fractalBookmarks'); renderBookmarks(); }
  });
  document.getElementById('btnShare').addEventListener('click', ()=>{
    updateHash();
    const url=location.href;
    navigator.clipboard.writeText(url).then(()=>{ const b=document.getElementById('btnShare'); const t=b.textContent; b.textContent='Copied!'; setTimeout(()=>b.textContent=t,1200); });
  });
  // initial — render synchronously so first paint is immediate
  updateLUT();
  updateJuliaToggle();
  updateHUD();
  renderId++;
  renderProgressive(renderId);
  schedulePreview();
  resetHudIdle();
  // FPS loop
  function fpsLoop(){
    frames++;
    const now=performance.now();
    if (now - lastFpsTime > 500){
      fpsVal = Math.round(frames*1000/(now-lastFpsTime));
      document.getElementById('fps').textContent = fpsVal + ' fps';
      frames=0; lastFpsTime=now;
    }
    requestAnimationFrame(fpsLoop);
  }
  fpsLoop();
  // overlay draw loop
  setInterval(drawOverlay, 50);
  // expose debug interface
  window.fractal = {
    iterationsAt,
    juliaIterationsAt,
    setView,
    getView,
    get view(){ return {...view}; },
    get juliaC(){ return {...juliaC}; },
    get palette(){ return paletteKey; },
    set palette(v){ if (CONFIG.palettes[v]){ paletteKey=v; updatePaletteUI(); updateLUT(); scheduleRender(); }},
    render: scheduleRender,
    CONFIG
  };
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"2d953891-5a8b-4293-a360-0d7f44b06a7a","tokensIn":2012063,"tokensOut":31974,"tokensTotal":2044037,"cost":0.034194118,"turns":40,"toolCalls":39,"failedToolCalls":0,"timestamp":"2026-08-23T00:43:05.202Z"} -->