← Fractal Explorer results

Fractal Explorer

muse spark 1.2 · basic

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 37,809 bytes · SHA-256 a56acf03f87b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
  :root{--bg:#06070a;--glass:rgba(18,20,28,0.72);--border:rgba(255,255,255,0.09);--text:#eef2ff;--muted:#9aa3b8;--acc:#7cf8d6;--acc2:#8b7cff}
  *{box-sizing:border-box}
  html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;overflow:hidden}
  #wrap{position:relative;width:100vw;height:100vh;overflow:hidden;background:radial-gradient(1200px 800px at 30% 20%,#1a2440 0%,#0a0e1a 45%,#05070c 100%)}
  canvas#main{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
  canvas#preview{width:160px;height:160px;border-radius:14px;background:#000;display:block}
  /* HUD */
  .hud{position:absolute;backdrop-filter:blur(18px) saturate(140%);background:var(--glass);border:1px solid var(--border);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,0.45),inset 0 1px 0 rgba(255,255,255,0.06)}
  .topbar{top:14px;left:14px;right:14px;display:flex;align-items:center;gap:12px;padding:10px 12px;flex-wrap:wrap}
  .brand{font-weight:700;letter-spacing:.02em;display:flex;align-items:center;gap:10px}
  .brand i{width:28px;height:28px;border-radius:9px;background:conic-gradient(from 0deg,#7cf8d6,#8b7cff,#ff6b9d,#ffbe4c,#7cf8d6);display:grid;place-items:center;color:#000;font-weight:900;font-size:14px}
  .readouts{display:flex;gap:14px;flex-wrap:wrap;font:12px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:#cbd5e1}
  .readouts b{color:#fff;font-weight:600}
  .chip{padding:4px 8px;border-radius:999px;background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.08)}
  .leftpanel{left:14px;top:78px;bottom:14px;width:300px;padding:14px;display:flex;flex-direction:column;gap:14px;overflow:auto;scrollbar-width:thin}
  .rightpreview{right:14px;top:78px;padding:10px;display:flex;flex-direction:column;gap:10px;align-items:center;min-width:184px}
  .panel-title{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);font-weight:700}
  .row{display:flex;gap:8px;flex-wrap:wrap}
  button,select,input{font:inherit}
  .btn{appearance:none;border:1px solid var(--border);background:rgba(255,255,255,0.06);color:var(--text);padding:8px 11px;border-radius:10px;cursor:pointer;font-size:13px;transition:.15s;display:inline-flex;align-items:center;gap:6px}
  .btn:hover{background:rgba(255,255,255,0.11);transform:translateY(-1px)}
  .btn.primary{background:linear-gradient(135deg,#7cf8d6,#8b7cff);color:#0a0a0f;border-color:transparent;font-weight:700}
  .btn:active{transform:translateY(0)}
  .iconbtn{width:32px;height:32px;display:grid;place-items:center;padding:0;border-radius:9px}
  .swatches{display:flex;gap:8px}
  .swatch{width:36px;height:28px;border-radius:8px;border:2px solid transparent;cursor:pointer;position:relative;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,0.2)}
  .swatch.active{border-color:#fff;box-shadow:0 0 0 2px rgba(124,248,214,0.6)}
  .slider{width:100%;accent-color:#7cf8d6}
  label{font-size:12px;color:var(--muted);display:flex;justify-content:space-between;align-items:center;gap:8px}
  label span{color:var(--text);font-family:ui-monospace,monospace;font-size:12px}
  .kbd{font:11px ui-monospace,monospace;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);border-bottom-width:2px;padding:2px 6px;border-radius:6px;color:#e2e8f0}
  .divider{height:1px;background:var(--border);margin:2px 0}
  .zoombox{position:absolute;border:1px solid #fff;box-shadow:0 0 0 1px #000,0 0 12px rgba(124,248,214,0.6);pointer-events:none;display:none;mix-blend-mode:difference}
  .cross{position:absolute;width:20px;height:20px;pointer-events:none;display:none}
  .cross:before,.cross:after{content:"";position:absolute;background:#fff;mix-blend-mode:difference}
  .cross:before{left:9px;top:0;width:2px;height:20px}.cross:after{top:9px;left:0;width:20px;height:2px}
  .hint{font-size:11px;color:#a7b0c6;line-height:1.5}
  .bookmarks{display:flex;flex-direction:column;gap:6px;max-height:140px;overflow:auto}
  .bm{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.06);font-size:12px}
  .bm small{color:var(--muted);font-family:ui-monospace,monospace}
  #toast{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);background:rgba(10,12,18,0.9);border:1px solid var(--border);padding:8px 14px;border-radius:999px;font-size:12px;display:none}
  @media(max-width:900px){.leftpanel{width:260px} .rightpreview{display:none}}
  @media(max-width:640px){.leftpanel{top:auto;height:44vh;right:14px;width:auto} .topbar{padding:8px}}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="main"></canvas>
  <div id="zoombox" class="zoombox"></div>
  <div id="cross" class="cross"></div>

  <!-- Top HUD -->
  <div class="hud topbar" id="topbar">
    <div class="brand"><i>∞</i> FRACTAL EXPLORER <span style="font-weight:400;color:var(--muted);font-size:12px;letter-spacing:0">Mandelbrot · Julia</span></div>
    <div style="flex:1"></div>
    <div class="readouts">
      <span class="chip">× <b id="mag">1.00</b></span>
      <span class="chip">C <b id="centerLbl">-0.5000 +0.0000i</b></span>
      <span class="chip">iter <b id="iterLbl">256</b></span>
      <span class="chip">fps <b id="fpsLbl">—</b></span>
      <span class="chip" id="precLbl" style="display:none;color:#ff8a8a;border-color:rgba(255,100,100,0.3)">precision limit</span>
    </div>
    <div class="row">
      <button class="btn iconbtn" id="backBtn" title="Back (history)">↩</button>
      <button class="btn" id="resetBtn">Reset</button>
      <button class="btn" id="juliaToggle">Julia: OFF</button>
      <button class="btn" id="shareBtn">Share link</button>
    </div>
  </div>

  <!-- Left Controls -->
  <div class="hud leftpanel" id="leftpanel">
    <div>
      <div class="panel-title">Palette</div>
      <div class="swatches" id="swatches"></div>
      <div class="row" style="margin-top:8px">
        <label style="flex:1">Cycle <input id="cycle" type="range" min="0" max="100" value="0" class="slider"></label>
        <span class="kbd" id="cycleVal">0</span>
      </div>
      <label><span><input id="smoothChk" type="checkbox" checked> Smooth coloring</span></label>
    </div>
    <div class="divider"></div>
    <div>
      <div class="panel-title">Iterations & Depth</div>
      <label>Max iter <span id="iterVal">256</span></label>
      <input id="iterSlider" type="range" min="64" max="2048" step="32" value="256" class="slider">
      <label><span><input id="autoIterChk" type="checkbox" checked> Auto-scale with zoom</span> <span id="autoIterVal" style="color:var(--muted)">on</span></label>
      <div class="hint">Deeper zooms auto-increase iterations. Uncheck to lock slider. Limit ~5000.</div>
    </div>
    <div class="divider"></div>
    <div>
      <div class="panel-title">Navigate</div>
      <div class="hint">
        <span class="kbd">Drag</span> pan · <span class="kbd">Shift+Drag</span> rectangle zoom · <span class="kbd">Wheel</span> zoom to cursor<br>
        <span class="kbd">Arrows</span> pan · <span class="kbd">+/-</span> zoom · <span class="kbd">J</span> toggle Julia · <span class="kbd">R</span> reset · <span class="kbd">B</span> back
      </div>
      <div class="row" style="margin-top:8px">
        <button class="btn" id="zoominBtn">Zoom +</button>
        <button class="btn" id="zoomoutBtn">Zoom −</button>
        <button class="btn" id="locateSeahorse">Seahorse Valley</button>
      </div>
    </div>
    <div class="divider"></div>
    <div>
      <div class="panel-title">Bookmarks</div>
      <div class="row">
        <input id="bmName" placeholder="Name this view" style="flex:1;background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--text);padding:7px 9px;border-radius:9px">
        <button class="btn primary" id="saveBm">Save</button>
      </div>
      <div class="bookmarks" id="bmList"></div>
    </div>
    <div class="hint" style="margin-top:auto">Double-precision limit ~1e13×. Zoom is clamped before precision turns to noise.</div>
  </div>

  <!-- Julia Preview -->
  <div class="hud rightpreview" id="juliaPanel">
    <div class="panel-title">Julia Preview <span style="text-transform:none;letter-spacing:0;color:#7cf8d6;font-size:10px" id="juliaCLabel">c = —</span></div>
    <canvas id="preview" width="160" height="160"></canvas>
    <div class="hint" style="text-align:center">Hover Mandelbrot to preview.<br>Click or press <span class="kbd">J</span> to open Julia.</div>
    <div class="row"><button class="btn" id="swapJulia">Open Julia →</button></div>
  </div>

  <div id="toast"></div>
</div>

<script>
/* ============================================================
   TUNABLE CONSTANTS — edit to taste
   ============================================================ */
const CONFIG = {
  baseWidth: 3.5,              // complex plane width at 1×
  initCenterRe: -0.5,
  initCenterIm: 0,
  initScale: 3.5,              // == baseWidth
  maxMagnification: 1e13,      // precision cap
  minScale: 3.5/1e13,
  baseIterations: 256,
  iterScaleFactor: 55,         // added per log2(mag)
  maxIterationsHard: 5000,
  zoomSpeed: 0.0018,           // wheel
  keyZoomFactor: 1.22,
  panStep: 0.12,               // fraction of view width per arrow
  progressiveSteps: [4,2,1],   // coarse → fine
  previewSize: 160,
  fpsSample: 600,
};

/* Palettes — 4 curated */
const PALETTES = {
  fire: [ [0,0,0],[0.08,0.03,0.10],[0.45,0.05,0.08],[0.9,0.22,0.05],[1,0.85,0.2],[1,1,0.95] ],
  ocean:[ [0,0,0],[0.02,0.05,0.18],[0.05,0.2,0.45],[0.15,0.55,0.78],[0.55,0.85,0.95],[0.95,1,1] ],
  mono: [ [0,0,0],[0.08,0.08,0.09],[0.25,0.25,0.27],[0.55,0.55,0.58],[0.85,0.85,0.88],[1,1,1] ],
  psychedelic:[ [0,0,0],[0.35,0.05,0.45],[0.05,0.55,0.95],[0.25,0.95,0.35],[0.95,0.9,0.1],[0.95,0.15,0.25],[0.35,0.05,0.45] ]
};
function paletteToRGBStops(name){
  const stops = PALETTES[name];
  return stops.map(([r,g,b])=>[Math.round(r*255),Math.round(g*255),Math.round(b*255)]);
}

/* ============================================================
   STATE
   ============================================================ */
let view = { re: CONFIG.initCenterRe, im: CONFIG.initCenterIm, scale: CONFIG.initScale };
let juliaC = { re: -0.8, im: 0.156 };
let isJulia = false;
let paletteName = 'fire';
let paletteStops = paletteToRGBStops(paletteName);
let cycleOffset = 0; // 0..1
let smoothOn = true;
let autoIter = true;
let manualIter = 256;
let historyStack = [];
let hoverC = {re:0,im:0};
let animFrame = 0;

/* Canvas */
const canvas = document.getElementById('main');
const ctx = canvas.getContext('2d', {alpha:false});
const preview = document.getElementById('preview');
const pctx = preview.getContext('2d', {alpha:false});
const zoombox = document.getElementById('zoombox');
const cross = document.getElementById('cross');

function resize(){
  const dpr = Math.min(window.devicePixelRatio||1, 2);
  const w = canvas.clientWidth||window.innerWidth;
  const h = canvas.clientHeight||window.innerHeight;
  canvas.width = Math.round(w*dpr);
  canvas.height = Math.round(h*dpr);
  ctx.imageSmoothingEnabled = false;
  // keep css size via style? canvas is absolute 100%
  canvas.style.width = w+'px';
  canvas.style.height = h+'px';
  scheduleRender();
  schedulePreview();
}
window.addEventListener('resize', resize);

/* ============================================================
   MATH — allocation-free escape loop
   ============================================================ */
function iterationsAt(re, im, maxIter, useJulia=false, jRe=juliaC.re, jIm=juliaC.im){
  let zr, zi, cr, ci;
  if(useJulia){ // Julia: z0 = point, c = juliaC
    zr=re; zi=im; cr=jRe; ci=jIm;
  } else { // Mandelbrot: z0=0, c=point
    zr=0; zi=0; cr=re; ci=im;
  }
  let zr2=0, zi2=0, iter=0;
  for(; iter<maxIter; iter++){
    // z = z^2 + c
    // compute zi first using old zr
    const nzr = zr2 - zi2 + cr;
    const nzi = 2*zr*zi + ci;
    zr = nzr; zi = nzi;
    zr2 = zr*zr; zi2 = zi*zi;
    if(zr2+zi2 > 4){
      if(!smoothOn) return iter+1;
      const mod = Math.sqrt(zr2+zi2);
      // avoid log <=0
      const log_zn = Math.log(mod);
      // smooth = iter+1 - log2(log|z|)
      const smooth = iter + 1 - Math.log(log_zn)/Math.log(2);
      return smooth;
    }
  }
  return maxIter; // interior
}
// for debug interface raw loop without smooth
function rawEscape(re,im,maxIter){
  let zr=0,zi=0,zr2=0,zi2=0;
  for(let i=0;i<maxIter;i++){
    const nzr = zr2 - zi2 + re;
    const nzi = 2*zr*zi + im;
    zr=nzr; zi=nzi; zr2=zr*zr; zi2=zi*zi;
    if(zr2+zi2>4) return i+1;
  }
  return maxIter;
}

function getMaxIter(){
  if(!autoIter) return manualIter;
  const mag = CONFIG.baseWidth / view.scale;
  const extra = Math.max(0, Math.log2(mag)) * CONFIG.iterScaleFactor;
  return Math.min(CONFIG.maxIterationsHard, Math.round(CONFIG.baseIterations + extra));
}

/* Mapping */
function pixelToComplex(x,y, w, h, v){
  const pixelSize = v.scale / w;
  const re = v.re + (x - w/2)*pixelSize;
  const im = v.im + (y - h/2)*pixelSize;
  return [re,im];
}
function complexToPixel(re,im,w,h,v){
  const ps = v.scale / w;
  return [(re - v.re)/ps + w/2, (im - v.im)/ps + h/2];
}

/* ============================================================
   COLORIZING
   ============================================================ */
function lerp(a,b,t){return a + (b-a)*t}
function colorFor(t, stops, offset){
  // t 0..1 (normalized smooth iter) + offset wrap
  let u = (t + offset) % 1; if(u<0) u+=1;
  const n = stops.length-1; // last is duplicate for mono? but we cycle
  const scaled = u * n;
  const i = Math.floor(scaled);
  const f = scaled - i;
  const c0 = stops[i], c1 = stops[Math.min(i+1,n)];
  // interior black handled outside
  return [ Math.round(lerp(c0[0],c1[0],f)), Math.round(lerp(c0[1],c1[1],f)), Math.round(lerp(c0[2],c1[2],f)) ];
}

function colorizeBuffer(iterBuf, w, h, maxIter, stops, offset, imageData){
  const data = imageData.data;
  const n = stops.length;
  // pre avoid per-pixel allocation
  for(let i=0;i<w*h;i++){
    const it = iterBuf[i];
    const idx = i*4;
    if(it >= maxIter - 1e-6){ // interior
      data[idx]=0; data[idx+1]=0; data[idx+2]=0; data[idx+3]=255;
    } else {
      // normalized: use smooth iter / maxIter but with periodicity for psychedelic: scale frequency
      // Use log-ish mapping to keep detail: t = it / maxIter
      let t = it / maxIter;
      // add a slight power to boost contrast in low iter regions
      // keep linear for smooth bands
      if(paletteName==='psychedelic'){ t = (it * 0.07 + offset*2) % 1; // override offset handling later
        // we will compute color differently
        const u = t %1;
        const scaled = u * (n-1);
        const ii = Math.floor(scaled); const ff = scaled-ii;
        const c0=stops[ii], c1=stops[Math.min(ii+1,n-1)];
        data[idx]=Math.round(lerp(c0[0],c1[0],ff));
        data[idx+1]=Math.round(lerp(c0[1],c1[1],ff));
        data[idx+2]=Math.round(lerp(c0[2],c1[2],ff));
        data[idx+3]=255;
      } else {
        const col = colorFor(t, stops, offset);
        data[idx]=col[0]; data[idx+1]=col[1]; data[idx+2]=col[2]; data[idx+3]=255;
      }
    }
  }
}

/* ============================================================
   RENDERING PIPELINE — progressive, cancellable, chunked
   ============================================================ */
let renderGen = 0;
let fps = 0, lastFrame=performance.now(), frames=0;
let isRendering=false;

function scheduleRender(){
  renderGen++;
  const gen = renderGen;
  isRendering=true;
  const w = canvas.width, h = canvas.height;
  const maxIter = getMaxIter();
  document.getElementById('iterLbl').textContent = maxIter;
  document.getElementById('iterVal').textContent = manualIter;
  // update HUD readouts
  const mag = CONFIG.baseWidth / view.scale;
  document.getElementById('mag').textContent = mag < 1e4 ? mag.toFixed(2) : mag.toExponential(2);
  document.getElementById('centerLbl').textContent = `${view.re.toFixed(6)} ${view.im>=0?'+':''}${view.im.toFixed(6)}i`;
  const precLbl = document.getElementById('precLbl');
  precLbl.style.display = mag > CONFIG.maxMagnification*0.8 ? 'inline-block' : 'none';
  // create temp canvases for scaling
  const steps = CONFIG.progressiveSteps;
  let stepIdx=0;

  function renderPass(){
    if(gen!==renderGen) return;
    const step = steps[stepIdx];
    const lw = Math.ceil(w/step), lh = Math.ceil(h/step);
    const iterBuf = new Float32Array(lw*lh);
    const pixelSize = view.scale / w;
    // chunked compute
    let y=0;
    const chunkRows = step===1 ? 12 : lh; // coarse does all at once, fine chunked
    // For better responsiveness, split fine into chunks per rAF
    function computeChunk(){
      if(gen!==renderGen) return;
      const endY = Math.min(y + chunkRows, lh);
      const useJulia = isJulia;
      const jRe=juliaC.re, jIm=juliaC.im;
      // inner loops allocation-free
      for(; y<endY; y++){
        const im = view.im + ((y*step + step/2 - h/2))*pixelSize;
        for(let x=0;x<lw;x++){
          const re = view.re + ((x*step + step/2 - w/2))*pixelSize;
          // inline iterationsAt to avoid function call overhead per pixel? keep call but okay
          // manual inline for speed
          let zr, zi, cr, ci;
          if(useJulia){ zr=re; zi=im; cr=jRe; ci=jIm; } else { zr=0; zi=0; cr=re; ci=im; }
          let zr2=0, zi2=0, iter=0;
          let escaped=false; let smoothIter=0;
          for(; iter<maxIter; iter++){
            const nzr = zr2 - zi2 + cr;
            const nzi = 2*zr*zi + ci;
            zr=nzr; zi=nzi; zr2=zr*zr; zi2=zi*zi;
            if(zr2+zi2>4){ escaped=true;
              if(smoothOn){
                const mod=Math.sqrt(zr2+zi2);
                const lg=Math.log(mod);
                smoothIter = iter + 1 - Math.log(lg)/Math.log(2);
              } else smoothIter = iter+1;
              break;
            }
          }
          iterBuf[y*lw + x] = escaped ? smoothIter : maxIter;
        }
      }
      if(y < lh){
        requestAnimationFrame(computeChunk);
      } else {
        // colorize and blit
        if(gen!==renderGen) return;
        const img = new ImageData(lw,lh);
        // for psychedelic we pass offset differently but colorize handles
        colorizeBuffer(iterBuf, lw, lh, maxIter, paletteStops, cycleOffset, img);
        if(step===1){
          // direct put
          // create full ImageData if lw==w else need to scale? when step 1 lw==w
          ctx.putImageData(img,0,0);
          // done all passes
          isRendering=false;
          frames++; // count
          stepIdx++;
          if(stepIdx < steps.length) requestAnimationFrame(renderPass);
          else {
            // final FPS update will be in rAF loop
          }
        } else {
          // scale up via temp canvas
          const tmp = document.createElement('canvas');
          tmp.width=lw; tmp.height=lh;
          const tctx=tmp.getContext('2d');
          tctx.putImageData(img,0,0);
          ctx.imageSmoothingEnabled=false;
          ctx.drawImage(tmp,0,0,w,h);
          // next pass
          stepIdx++;
          if(stepIdx < steps.length) requestAnimationFrame(renderPass);
          else isRendering=false;
        }
      }
    }
    requestAnimationFrame(computeChunk);
  }
  renderPass();
  saveHashDebounced();
}
let hashTimer=null;
function saveHashDebounced(){
  clearTimeout(hashTimer);
  hashTimer=setTimeout(saveHash,200);
}
function saveHash(){
  const o={re:view.re, im:view.im, scale:view.scale, pal:paletteName, cyc:cycleOffset, julia:isJulia, jr:juliaC.re, ji:juliaC.im, mi: manualIter, au:autoIter};
  location.hash = encodeURIComponent(JSON.stringify(o));
}
function loadHash(){
  if(!location.hash) return false;
  try{
    const o=JSON.parse(decodeURIComponent(location.hash.slice(1)));
    if(o.re!==undefined){ view.re=o.re; view.im=o.im; view.scale=o.scale; paletteName=o.pal||paletteName; paletteStops=paletteToRGBStops(paletteName); cycleOffset=o.cyc||0; isJulia=!!o.julia; juliaC.re=o.jr??juliaC.re; juliaC.im=o.ji??juliaC.im; manualIter=o.mi??manualIter; autoIter=o.au??autoIter; return true; }
  }catch(e){}
  return false;
}

/* Preview render (small, fast) */
let previewGen=0;
function schedulePreview(){
  previewGen++;
  const gen=previewGen;
  const w=preview.width, h=preview.height;
  const tmpView = isJulia ? view : {re:0, im:0, scale:3.2}; // Julia preview centered
  // For hover preview, we need julia set for hoverC
  const cRe = hoverC.re, cIm = hoverC.im;
  document.getElementById('juliaCLabel').textContent = `c = ${cRe.toFixed(4)}${cIm>=0?'+':''}${cIm.toFixed(4)}i`;
  const maxIter = 256;
  const lw=w, lh=h;
  const iterBuf = new Float32Array(lw*lh);
  const pixelSize = tmpView.scale / w;
  // quick full compute (small)
  for(let y=0;y<lh;y++){
    if(gen!==previewGen) return;
    const im = tmpView.im + (y - lh/2)*pixelSize;
    for(let x=0;x<lw;x++){
      const re = tmpView.re + (x - lw/2)*pixelSize;
      // julia: z0 = re+im*i, c = hoverC
      let zr=re, zi=im, zr2=zr*zr, zi2=zi*zi;
      let iter=0, smooth=0, escaped=false;
      for(; iter<maxIter; iter++){
        const nzr = zr2 - zi2 + cRe;
        const nzi = 2*zr*zi + cIm;
        zr=nzr; zi=nzi; zr2=zr*zr; zi2=zi*zi;
        if(zr2+zi2>4){ escaped=true;
          const mod=Math.sqrt(zr2+zi2); const lg=Math.log(mod);
          smooth = iter+1 - Math.log(lg)/Math.log(2);
          break;
        }
      }
      iterBuf[y*lw+x]= escaped? smooth: maxIter;
    }
  }
  const img=new ImageData(lw,lh);
  colorizeBuffer(iterBuf,lw,lh,maxIter,paletteStops,cycleOffset,img);
  pctx.putImageData(img,0,0);
}

/* ============================================================
   INTERACTION
   ============================================================ */
let isPanning=false, isBoxing=false, startX=0,startY=0, lastX=0,lastY=0, panView=null;
const dpr =()=> canvas.width / (canvas.clientWidth||window.innerWidth);

canvas.addEventListener('mousedown', e=>{
  if(e.button===2) return;
  const rect=canvas.getBoundingClientRect();
  const scale = dpr();
  const x=(e.clientX-rect.left)*scale, y=(e.clientY-rect.top)*scale;
  startX=x; startY=y; lastX=x; lastY=y;
  if(e.shiftKey){
    isBoxing=true;
    zoombox.style.display='block';
    zoombox.style.left=(x/scale)+'px'; zoombox.style.top=(y/scale)+'px';
    zoombox.style.width='0px'; zoombox.style.height='0px';
  } else {
    isPanning=true;
    panView={...view};
    canvas.style.cursor='grabbing';
  }
});
window.addEventListener('mousemove', e=>{
  const rect=canvas.getBoundingClientRect();
  const scale=dpr();
  const x=(e.clientX-rect.left)*scale, y=(e.clientY-rect.top)*scale;
  // hover handling for julia preview
  const [re,im]=pixelToComplex(x,y,canvas.width,canvas.height,view);
  hoverC.re=re; hoverC.im=im;
  // crosshair
  if(!isJulia){
    cross.style.display='block';
    cross.style.left=(e.clientX-rect.left-10)+'px';
    cross.style.top=(e.clientY-rect.top-10)+'px';
  }
  // throttle preview
  if(!isPanning && !isBoxing){
    if(animFrame%6===0) schedulePreview();
  }
  if(isBoxing){
    const sx=Math.min(startX,x), sy=Math.min(startY,y);
    const sw=Math.abs(x-startX), sh=Math.abs(y-startY);
    const zbs=zoombox.style;
    zbs.left=(sx/scale)+'px'; zbs.top=(sy/scale)+'px';
    zbs.width=(sw/scale)+'px'; zbs.height=(sh/scale)+'px';
  } else if(isPanning){
    const dx = x - startX, dy = y - startY;
    const ps = panView.scale / canvas.width;
    view.re = panView.re - dx*ps;
    view.im = panView.im - dy*ps;
    // live update with coarse render throttle
    if(animFrame%3===0) scheduleRender();
  }
  lastX=x; lastY=y;
});
window.addEventListener('mouseup', e=>{
  const rect=canvas.getBoundingClientRect();
  const scale=dpr();
  const x=(e.clientX-rect.left)*scale, y=(e.clientY-rect.top)*scale;
  if(isBoxing){
    isBoxing=false;
    zoombox.style.display='none';
    const sx=Math.min(startX,x), sy=Math.min(startY,y);
    const sw=Math.abs(x-startX), sh=Math.abs(y-startY);
    if(sw>8*scale && sh>8*scale){
      // zoom to rectangle exactly
      pushHistory();
      // compute new view that contains exactly that rect, preserving aspect via scale = width in complex plane
      // sx,sy,sw,sh in device pixels (canvas.width). Convert to complex rect
      const ps = view.scale / canvas.width;
      const re0 = view.re + (sx - canvas.width/2)*ps;
      const re1 = view.re + (sx+sw - canvas.width/2)*ps;
      const im0 = view.im + (sy - canvas.height/2)*ps;
      const im1 = view.im + (sy+sh - canvas.height/2)*ps;
      const selW = Math.abs(re1-re0);
      const selH = Math.abs(im1-im0);
      const selCenterRe = (re0+re1)/2;
      const selCenterIm = (im0+im1)/2;
      // aspect handling: we set scale to selW, but need to ensure selH fits vertically.
      // canvas aspect = h/w. Required scaleH = selH * (canvas.width/canvas.height)
      // We choose scale = max(selW, selH * canvas.width/canvas.height) to contain selection without shift.
      const aspect = canvas.height / canvas.width;
      const requiredScaleH = selH / aspect;
      const newScale = Math.max(selW, requiredScaleH);
      // clamp precision
      const capped = Math.max(newScale, CONFIG.minScale);
      view.re = selCenterRe;
      view.im = selCenterIm;
      view.scale = capped;
      // Check precision cap warning
      if(capped===CONFIG.minScale) toast("Max zoom reached (1e13×) — precision limit");
      scheduleRender();
    }
  }
  if(isPanning){
    isPanning=false;
    canvas.style.cursor='crosshair';
    // if tiny drag treat as click for julia? only if not moved much
    const dist=Math.hypot(x-startX,y-startY);
    if(dist < 4*scale && !e.shiftKey){
      // click selects Julia c if not in Julia mode; otherwise maybe recenter?
      if(!isJulia){
        const [cr,ci]=pixelToComplex(x,y,canvas.width,canvas.height,view);
        juliaC.re=cr; juliaC.im=ci;
        schedulePreview();
        // subtle feedback
        toast(`Julia c = ${cr.toFixed(5)} ${ci>=0?'+':''}${ci.toFixed(5)}i — press J to open`);
      }
    } else {
      // pan settled, already scheduled
      scheduleRender();
    }
  }
});
canvas.addEventListener('wheel', e=>{
  e.preventDefault();
  const rect=canvas.getBoundingClientRect();
  const scale=dpr();
  const mx=(e.clientX-rect.left)*scale, my=(e.clientY-rect.top)*scale;
  const [cre,cim]=pixelToComplex(mx,my,canvas.width,canvas.height,view);
  const factor = Math.exp(-e.deltaY * CONFIG.zoomSpeed);
  // clamp
  let newScale = view.scale / factor;
  newScale = Math.max(CONFIG.minScale, Math.min(CONFIG.baseWidth*2, newScale));
  if(newScale===CONFIG.minScale && factor>1) { toast("Max zoom (1e13×)"); return; }
  pushHistoryIfNeeded();
  // zoom toward cursor
  const psNew = newScale / canvas.width;
  view.re = cre - (mx - canvas.width/2)*psNew;
  view.im = cim - (my - canvas.height/2)*psNew;
  view.scale = newScale;
  scheduleRender();
},{passive:false});
canvas.addEventListener('contextmenu', e=>e.preventDefault());

/* Keyboard */
window.addEventListener('keydown', e=>{
  if(e.target.tagName==='INPUT') return;
  const step = view.scale * CONFIG.panStep;
  let handled=true;
  switch(e.key){
    case 'ArrowLeft': pushHistory(); view.re-=step; break;
    case 'ArrowRight': view.re+=step; break;
    case 'ArrowUp': view.im-=step; break;
    case 'ArrowDown': view.im+=step; break;
    case '+': case '=': pushHistory(); view.scale/=CONFIG.keyZoomFactor; view.scale=Math.max(CONFIG.minScale,view.scale); break;
    case '-': case '_': pushHistory(); view.scale*=CONFIG.keyZoomFactor; break;
    case 'j': case 'J': toggleJulia(); break;
    case 'r': case 'R': resetView(); break;
    case 'b': case 'B': goBack(); break;
    default: handled=false;
  }
  if(handled){ e.preventDefault(); scheduleRender(); }
});

/* History */
function pushHistory(){
  historyStack.push({re:view.re, im:view.im, scale:view.scale});
  if(historyStack.length>40) historyStack.shift();
}
let lastPush=0;
function pushHistoryIfNeeded(){
  if(performance.now()-lastPush>300){ pushHistory(); lastPush=performance.now(); }
}
function goBack(){
  if(!historyStack.length) return toast("No history");
  const v=historyStack.pop();
  view.re=v.re; view.im=v.im; view.scale=v.scale;
  scheduleRender();
}
function resetView(){
  pushHistory();
  view.re=CONFIG.initCenterRe; view.im=CONFIG.initCenterIm; view.scale=CONFIG.initScale;
  isJulia=false; updateJuliaBtn();
  scheduleRender();
}

/* Julia toggle */
function toggleJulia(){
  if(!isJulia){
    // open with current hover/selected c
    pushHistory();
    isJulia=true;
    // keep view center but adjust scale if needed
    // optionally center julia view at 0,0 scale 3.2
    view.re=0; view.im=0; view.scale=3.2;
  } else {
    pushHistory();
    isJulia=false;
    view.re=CONFIG.initCenterRe; view.im=CONFIG.initCenterIm; view.scale=CONFIG.initScale;
  }
  updateJuliaBtn();
  scheduleRender();
  schedulePreview();
}
function updateJuliaBtn(){
  const btn=document.getElementById('juliaToggle');
  btn.textContent = isJulia ? `Julia: ON (c ${juliaC.re.toFixed(2)}${juliaC.im>=0?'+':''}${juliaC.im.toFixed(2)}i)` : 'Julia: OFF';
  btn.classList.toggle('primary', isJulia);
}

/* Controls */
function initControls(){
  const swWrap=document.getElementById('swatches');
  Object.keys(PALETTES).forEach(name=>{
    const d=document.createElement('button');
    d.className='swatch'+(name===paletteName?' active':'');
    d.title=name;
    const stops=paletteToRGBStops(name);
    // gradient preview
    d.style.background=`linear-gradient(90deg, ${stops.map(c=>`rgb(${c[0]},${c[1]},${c[2]})`).join(',')})`;
    d.addEventListener('click',()=>{
      paletteName=name; paletteStops=paletteToRGBStops(name);
      document.querySelectorAll('.swatch').forEach(s=>s.classList.remove('active'));
      d.classList.add('active');
      scheduleRender(); schedulePreview();
    });
    swWrap.appendChild(d);
  });
  document.getElementById('cycle').addEventListener('input', e=>{
    cycleOffset = e.target.value/100;
    document.getElementById('cycleVal').textContent=e.target.value;
    // recolor only
    scheduleRender(); schedulePreview();
  });
  document.getElementById('smoothChk').addEventListener('change', e=>{ smoothOn=e.target.checked; scheduleRender(); schedulePreview(); });
  document.getElementById('iterSlider').addEventListener('input', e=>{
    manualIter=parseInt(e.target.value); document.getElementById('iterVal').textContent=manualIter;
    if(!autoIter) scheduleRender();
  });
  document.getElementById('autoIterChk').addEventListener('change', e=>{
    autoIter=e.target.checked; document.getElementById('autoIterVal').textContent=autoIter?'on':'off';
    document.getElementById('iterSlider').disabled=autoIter;
    scheduleRender();
  });
  document.getElementById('backBtn').addEventListener('click', goBack);
  document.getElementById('resetBtn').addEventListener('click', resetView);
  document.getElementById('juliaToggle').addEventListener('click', toggleJulia);
  document.getElementById('swapJulia').addEventListener('click', toggleJulia);
  document.getElementById('zoominBtn').addEventListener('click', ()=>{pushHistory(); view.scale/=1.4; view.scale=Math.max(CONFIG.minScale,view.scale); scheduleRender();});
  document.getElementById('zoomoutBtn').addEventListener('click', ()=>{pushHistory(); view.scale*=1.4; scheduleRender();});
  document.getElementById('locateSeahorse').addEventListener('click', ()=>{
    pushHistory();
    // seahorse valley ~ -0.74364 +0.131825i
    view.re=-0.743643887037151; view.im=0.13182590420533; view.scale=0.0008;
    scheduleRender();
  });
  document.getElementById('shareBtn').addEventListener('click', ()=>{
    saveHash(); navigator.clipboard.writeText(location.href).then(()=>toast("Link copied!"),()=>toast("Link: "+location.href));
  });
  document.getElementById('saveBm').addEventListener('click', saveBookmark);
}
function toast(msg){
  const t=document.getElementById('toast');
  t.textContent=msg; t.style.display='block';
  clearTimeout(t._tm); t._tm=setTimeout(()=>t.style.display='none',2200);
}

/* Bookmarks localStorage */
function loadBookmarks(){
  try{ return JSON.parse(localStorage.getItem('fractalBookmarks')||'[]'); }catch{return []}
}
function saveBookmarks(list){ localStorage.setItem('fractalBookmarks', JSON.stringify(list)); renderBookmarks(); }
function saveBookmark(){
  const name=document.getElementById('bmName').value.trim()||`View ${new Date().toLocaleTimeString()}`;
  const list=loadBookmarks();
  list.unshift({name, view:{...view}, palette:paletteName, cycle:cycleOffset, isJulia, juliaC:{...juliaC}, manualIter, autoIter, date:Date.now()});
  if(list.length>20) list.pop();
  document.getElementById('bmName').value='';
  saveBookmarks(list);
  toast("Bookmark saved");
}
function renderBookmarks(){
  const list=loadBookmarks();
  const wrap=document.getElementById('bmList');
  wrap.innerHTML='';
  list.forEach((b,i)=>{
    const div=document.createElement('div'); div.className='bm';
    div.innerHTML=`<div><b>${b.name}</b><br><small>${b.view.re.toFixed(4)} ${b.view.im>=0?'+':''}${b.view.im.toFixed(4)}i ×${(CONFIG.baseWidth/b.view.scale).toExponential(1)} ${b.isJulia?' Julia':''}</small></div>`;
    const row=document.createElement('div'); row.style.display='flex'; row.style.gap='6px';
    const load=document.createElement('button'); load.className='btn'; load.textContent='Load'; load.onclick=()=>{
      view.re=b.view.re; view.im=b.view.im; view.scale=b.view.scale;
      paletteName=b.palette; paletteStops=paletteToRGBStops(paletteName);
      cycleOffset=b.cycle; document.getElementById('cycle').value=Math.round(cycleOffset*100); document.getElementById('cycleVal').textContent=Math.round(cycleOffset*100);
      isJulia=b.isJulia; juliaC.re=b.juliaC.re; juliaC.im=b.juliaC.im;
      manualIter=b.manualIter; autoIter=b.autoIter;
      document.getElementById('autoIterChk').checked=autoIter;
      document.getElementById('iterSlider').value=manualIter;
      document.getElementById('iterSlider').disabled=autoIter;
      // refresh swatch active
      document.querySelectorAll('.swatch').forEach((s,idx)=> s.classList.toggle('active', Object.keys(PALETTES)[idx]===paletteName));
      updateJuliaBtn();
      scheduleRender(); schedulePreview();
      toast("Bookmark restored — identical view");
    };
    const del=document.createElement('button'); del.className='btn'; del.textContent='✕'; del.onclick=()=>{
      list.splice(i,1); saveBookmarks(list);
    };
    row.append(load,del); div.append(row); wrap.append(div);
  });
  if(!list.length) wrap.innerHTML='<div class="hint">No bookmarks yet. Save the current view.</div>';
}

/* ============================================================
   DEBUG INTERFACE on window
   ============================================================ */
window.fractal = {
  get view(){ return {...view}; },
  get juliaC(){return {...juliaC}},
  get isJulia(){return isJulia},
  get palette(){return paletteName},
  iterationsAt: (re,im, maxIter=getMaxIter(), useJulia=false)=> iterationsAt(re,im,maxIter,useJulia,juliaC.re,juliaC.im),
  rawEscape,
  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.julia!==undefined) isJulia=o.julia; if(o.jr!==undefined) juliaC.re=o.jr; if(o.ji!==undefined) juliaC.im=o.ji; if(o.palette) {paletteName=o.palette; paletteStops=paletteToRGBStops(paletteName);} scheduleRender(); schedulePreview(); },
  getView: ()=> ({...view, scale:view.scale, mag:CONFIG.baseWidth/view.scale, maxIter:getMaxIter(), palette:paletteName, julia:isJulia, juliaC:{...juliaC}}),
  render: ()=> scheduleRender(),
  _config: CONFIG
};

/* ============================================================
   INIT
   ============================================================ */
initControls();
renderBookmarks();
if(!loadHash()){
  // no hash
}
updateJuliaBtn();
resize();
schedulePreview();
// FPS loop
function fpsLoop(){
  animFrame++;
  const now=performance.now();
  if(now - lastFrame > CONFIG.fpsSample){
    fps = Math.round(frames * 1000 / (now - lastFrame));
    document.getElementById('fpsLbl').textContent = fps;
    frames=0; lastFrame=now;
  }
  requestAnimationFrame(fpsLoop);
}
fpsLoop();

/* Hide HUD when idle (elegant) — fade after 3s no interaction */
let idleTimer=null;
function bumpIdle(){ document.getElementById('leftpanel').style.opacity='1'; document.getElementById('topbar').style.opacity='1'; clearTimeout(idleTimer); idleTimer=setTimeout(()=>{ if(!isPanning && !isBoxing){ document.getElementById('leftpanel').style.opacity='0.18'; }},3000); }
['mousemove','mousedown','keydown','wheel'].forEach(ev=> window.addEventListener(ev,bumpIdle));
bumpIdle();

/* Self-check console hints */
console.log('%cFractal Explorer ready','color:#7cf8d6;font-weight:700');
console.log('Try: fractal.iterationsAt(0,0) →', window.fractal.iterationsAt(0,0));
console.log(' fractal.iterationsAt(2,0) →', window.fractal.iterationsAt(2,0));
console.log(' fractal.iterationsAt(-1,0) interior →', window.fractal.iterationsAt(-1,0));
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"7d449a3d-4e77-42b4-b736-e8494f539ee7","tokensIn":1189,"tokensOut":14098,"tokensTotal":15287,"cost":0.06140275,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:09:57.929Z"} -->