← Fractal Explorer results

Fractal Explorer

ox alpha · 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 31,252 bytes · SHA-256 409be53e6e28
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fractal Explorer — Mandelbrot &amp; Julia</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
/* ============================================================
   STYLES
   ============================================================ */
:root{
  --glass: rgba(16,18,24,.72);
  --glass-brd: rgba(255,255,255,.14);
  --txt: #e8ecf4;
  --dim: #9aa3b5;
  --accent: #6fb7ff;
}
*{box-sizing:border-box; margin:0; padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#000;
  font:13px/1.45 "Segoe UI",system-ui,-apple-system,sans-serif;color:var(--txt)}
#stage{position:fixed;inset:0}
canvas#view,canvas#overlay{position:absolute;inset:0;width:100%;height:100%;display:block}
canvas#view{cursor:crosshair}
#overlay{pointer-events:none}

/* --- HUD --- */
.hud{
  position:fixed; background:var(--glass); border:1px solid var(--glass-brd);
  border-radius:10px; padding:8px 12px; backdrop-filter:blur(10px);
  -webkit-backdrop-filter:blur(10px); transition:opacity .5s ease;
  box-shadow:0 4px 24px rgba(0,0,0,.45); user-select:none;
}
body.idle .hud{opacity:0; pointer-events:none}
#hudInfo{top:12px;left:12px; font-variant-numeric:tabular-nums; min-width:230px}
#hudInfo .row{display:flex;justify-content:space-between;gap:14px}
#hudInfo .k{color:var(--dim)}
#hudInfo .v{color:var(--txt)}
#hudInfo .mag{color:var(--accent);font-weight:600}

#hudCtrl{top:12px;right:12px; width:236px; display:flex;flex-direction:column;gap:8px}
.hud h1{font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:600}
.ctl{display:flex;flex-direction:column;gap:3px}
.ctl label{color:var(--dim);font-size:11px;display:flex;justify-content:space-between}
.ctl label output{color:var(--txt)}
input[type=range]{width:100%;accent-color:var(--accent);height:18px}
.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{width:34px;height:20px;border-radius:5px;border:2px solid transparent;cursor:pointer;padding:0}
.chip.sel{border-color:#fff;box-shadow:0 0 6px rgba(255,255,255,.6)}
.btnrow{display:flex;gap:6px;flex-wrap:wrap}
button{
  background:rgba(255,255,255,.08);color:var(--txt);border:1px solid var(--glass-brd);
  border-radius:6px;padding:4px 9px;cursor:pointer;font:inherit;font-size:12px
}
button:hover{background:rgba(255,255,255,.18)}
button.primary{background:rgba(111,183,255,.25);border-color:rgba(111,183,255,.5)}
input[type=text]{
  background:rgba(0,0,0,.4);border:1px solid var(--glass-brd);color:var(--txt);
  border-radius:6px;padding:4px 8px;font:inherit;width:100%
}
#bmList{max-height:110px;overflow-y:auto;display:flex;flex-direction:column;gap:3px}
#bmList .bm{display:flex;align-items:center;gap:6px}
#bmList .bm button{flex:1;text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#bmList .bm .del{flex:0 0 auto;padding:4px 7px;color:#ff9a9a}
#bmList .empty{color:var(--dim);font-size:11px}

/* --- Julia preview pane --- */
#previewPane{
  right:12px;bottom:12px;padding:6px;display:flex;flex-direction:column;gap:4px
}
#previewPane .cap{font-size:11px;color:var(--dim);text-align:center;font-variant-numeric:tabular-nums}
#preview{border-radius:6px;display:block;cursor:default}

/* --- toast / help --- */
#toast{
  left:50%;bottom:26px;transform:translateX(-50%);padding:8px 18px;
  opacity:0;transition:opacity .35s;pointer-events:none;white-space:nowrap
}
#toast.show{opacity:1}
#help{
  left:50%;top:50%;transform:translate(-50%,-50%);max-width:430px;line-height:1.7;display:none
}
#help.show{display:block}
#help kbd{
  background:rgba(255,255,255,.12);border:1px solid var(--glass-brd);border-radius:4px;
  padding:0 5px;font:11px ui-monospace,monospace
}
#setBadge{
  position:fixed;left:50%;top:12px;transform:translateX(-50%);
  font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--dim)
}
#setBadge b{color:var(--accent)}
</style>
</head>
<body>
<div id="stage">
  <canvas id="view"></canvas>
  <canvas id="overlay"></canvas>
</div>

<div id="setBadge">Set: <b id="setName">Mandelbrot</b> &nbsp;·&nbsp; <span id="juliaC"></span></div>

<div class="hud" id="hudInfo">
  <div class="row"><span class="k">Magnification</span><span class="v mag" id="hudMag">×1</span></div>
  <div class="row"><span class="k">Center</span><span class="v" id="hudCenter">—</span></div>
  <div class="row"><span class="k">Iterations</span><span class="v" id="hudIter">—</span></div>
  <div class="row"><span class="k">Render</span><span class="v" id="hudFps">—</span></div>
</div>

<div class="hud" id="hudCtrl">
  <h1>Fractal Explorer</h1>
  <div class="ctl">
    <label>Palette</label>
    <div class="chips" id="paletteChips"></div>
  </div>
  <div class="ctl">
    <label>Max iterations <output id="iterOut">auto</output></label>
    <input type="range" id="iterSlider" min="0" max="5000" step="50" value="0">
  </div>
  <div class="ctl">
    <label>Color cycle <output id="cycleOut">0</output></label>
    <input type="range" id="cycleSlider" min="0" max="4096" step="1" value="0">
  </div>
  <div class="ctl">
    <label>Color density <output id="densOut">3.0%</output></label>
    <input type="range" id="densSlider" min="5" max="120" step="1" value="30">
  </div>
  <div class="btnrow">
    <button id="btnBack" title="Back (B)">◀ Back</button>
    <button id="btnReset" title="Reset (R)">Reset</button>
    <button id="btnJulia" title="Swap Mandelbrot/Julia (J)">Julia</button>
    <button id="btnHelp" title="Help (H)">?</button>
  </div>
  <h1 style="margin-top:4px">Bookmarks</h1>
  <div class="btnrow">
    <input type="text" id="bmName" placeholder="view name…">
    <button id="bmSave" class="primary">Save</button>
  </div>
  <div id="bmList"><div class="empty">No bookmarks yet</div></div>
</div>

<div class="hud" id="previewPane">
  <canvas id="preview" width="220" height="165"></canvas>
  <div class="cap" id="previewCap">Julia preview</div>
</div>

<div class="hud" id="toast"></div>
<div class="hud" id="help">
  <h1 style="margin-bottom:6px">Controls</h1>
  <kbd>drag</kbd> rectangle zoom &nbsp; <kbd>Shift</kbd>+<kbd>drag</kbd> / middle-drag pan<br>
  <kbd>wheel</kbd> zoom to cursor &nbsp; <kbd>+</kbd>/<kbd>−</kbd> zoom &nbsp; <kbd>arrows</kbd> pan<br>
  <kbd>J</kbd> swap Mandelbrot ↔ Julia (c = hover point) &nbsp; <kbd>B</kbd> back &nbsp; <kbd>R</kbd> reset<br>
  <kbd>H</kbd> toggle this help. Hover the main view to live-preview the Julia set for that c.<br>
  Double precision holds to ~×1e13; deeper zooms are blocked (precision limit).
</div>

<script>
"use strict";
/* ============================================================
   1. TUNABLE CONSTANTS
   ============================================================ */
const CFG = {
  baseIter:      250,     // iteration budget at magnification ×1
  iterPerDecade: 130,     // extra iterations per decade of zoom
  iterCap:       20000,   // hard ceiling for auto iterations
  zoomSpeed:     1.25,    // per wheel notch zoom factor
  panKeyFrac:    0.12,    // arrow-key pan as fraction of view
  maxMag:        1e13,    // double-precision limit; block beyond
  passes:        [4,2,1], // progressive refinement steps (grid stride)
  sliceMs:       12,      // target time budget per render slice
  bailout2:      65536,   // escape radius² (R=256 → very smooth mu)
  colorLutSize:  4096,
  previewW:      220,
  previewH:      165,
  previewIter:   400,
  idleHideMs:    3200,    // HUD auto-hide delay
  initial:       { set:'mandel', re:-0.743643887037151, im:0.131825904205330, scale:3.0 }
};

/* Palettes: cyclic stops [pos, #hex] */
const PALETTES = [
  { name:'Ultra',   stops:[[0,'#000764'],[0.16,'#206bcb'],[0.42,'#edffff'],[0.6425,'#ffaa00'],[0.8575,'#310230'],[1,'#000764']] },
  { name:'Fire',    stops:[[0,'#000000'],[0.2,'#7f0000'],[0.45,'#ff4400'],[0.7,'#ffcc00'],[0.85,'#ffffff'],[1,'#000000']] },
  { name:'Ocean',   stops:[[0,'#000010'],[0.25,'#00337f'],[0.5,'#0099dd'],[0.75,'#aaffff'],[0.9,'#ffffff'],[1,'#000010']] },
  { name:'Mono',    stops:[[0,'#000000'],[0.5,'#ffffff'],[1,'#000000']] },
  { name:'Psychedelic', stops:[[0,'#ff0000'],[0.2,'#ffff00'],[0.4,'#00ff00'],[0.6,'#00ffff'],[0.8,'#0000ff'],[1,'#ff00ff']] }
];

/* ============================================================
   2. STATE
   ============================================================ */
const view = {
  set:  CFG.initial.set,   // 'mandel' | 'julia'
  re:   CFG.initial.re,    // center
  im:   CFG.initial.im,
  scale:CFG.initial.scale, // complex units spanning canvas HEIGHT
  jre:  -0.8, jim: 0.156,  // julia c
  pal:  0,                 // palette index
  iterOverride: 0,         // 0 = auto
  cycle: 0,                // palette cycle offset (LUT entries)
  density: 30              // color density (percent ×10 → 3.0%)
};
const history = [];        // zoom/pan history stack for Back
let W=0, H=0, dpr=1;
let renderToken = 0, renderScheduled = false;
let muBuf = null;          // full-res Float32 mu buffer (last completed full pass)
let img = null, imgData = null, imgU32 = null;
const LUT_N = CFG.colorLutSize;
const lut = new Uint8Array(LUT_N*3);
const INV_LN2 = 1/Math.LN2;
const B2 = CFG.bailout2;

/* DOM */
const cv = document.getElementById('view');
const ov = document.getElementById('overlay');
const pv = document.getElementById('preview');
const ctx = cv.getContext('2d');
const octx = ov.getContext('2d');
const pctx = pv.getContext('2d');
const $ = id => document.getElementById(id);

/* ============================================================
   3. COLOR — palette LUT (cyclic, smooth multi-stop interpolation)
   ============================================================ */
function hex2rgb(h){ return [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)]; }
function buildLUT(){
  const stops = PALETTES[view.pal].stops;
  for(let i=0;i<LUT_N;i++){
    const t = i/LUT_N;
    let a = stops[stops.length-1], b = stops[0];
    for(let s=0;s<stops.length-1;s++){
      if(t >= stops[s][0] && t <= stops[s+1][0]){ a=stops[s]; b=stops[s+1]; break; }
    }
    const f = (b[0]===a[0]) ? 0 : (t-a[0])/(b[0]-a[0]);
    const ca = hex2rgb(a[1]), cb = hex2rgb(b[1]);
    lut[i*3  ] = ca[0]+(cb[0]-ca[0])*f;
    lut[i*3+1] = ca[1]+(cb[1]-ca[1])*f;
    lut[i*3+2] = ca[2]+(cb[2]-ca[2])*f;
  }
}

/* ============================================================
   4. MATH — escape-time kernel (allocation-free)
   Returns smooth mu (>0) or -1 for interior.
   ============================================================ */
function iterate(ax, ay, maxIter, julia, cre, cim){
  let zr, zi, cx, cy;
  if(julia){ zr = ax; zi = ay; cx = cre; cy = cim; }
  else     { zr = 0;  zi = 0;  cx = ax;  cy = ay; }
  let zr2 = zr*zr, zi2 = zi*zi, n = 0;
  if(!julia){
    /* cardioid / period-2 bulb shortcut: guaranteed interior */
    const y1 = cx-0.25, q = y1*y1 + cy*cy;
    if(q*(q+y1) <= 0.25*cy*cy) return -1;
    const xp = cx+1;
    if(xp*xp + cy*cy <= 0.0625) return -1;
  }
  while(n < maxIter && zr2+zi2 <= B2){
    zi = 2*zr*zi + cy;
    zr = zr2 - zi2 + cx;
    zr2 = zr*zr; zi2 = zi*zi;
    n++;
  }
  if(n >= maxIter) return -1;
  /* smooth coloring: n + 1 - log2(log|z|) */
  return n + 1 - Math.log(0.5*Math.log(zr2+zi2))*INV_LN2;
}

function mag(){ return 3.0/view.scale; }
function autoIter(){ return Math.min(CFG.iterCap, Math.round(CFG.baseIter + CFG.iterPerDecade*Math.log10(Math.max(1,mag())))); }
function effIter(){ return view.iterOverride > 0 ? view.iterOverride : autoIter(); }
function pxSize(){ return view.scale/H; }              // complex units per pixel
function c2px(re,im){ return [ (re-view.re)/pxSize() + W/2, (im-view.im)/pxSize() + H/2 ]; }
function px2c(x,y){ const p = pxSize(); return [ view.re + (x-W/2)*p, view.im + (y-H/2)*p ]; }

/* ============================================================
   5. RENDER ENGINE — progressive passes into ImageData
   Each pass computes a strided grid of mu values, then
   colorizes full-res rows from the nearest grid point.
   Sliced across macrotasks; cancelled by token on view change.
   ============================================================ */
function resize(){
  dpr = Math.min(window.devicePixelRatio||1, 2);
  W = Math.floor(innerWidth*dpr); H = Math.floor(innerHeight*dpr);
  cv.width = W; cv.height = H;
  ov.width = W; ov.height = H;
  muBuf = new Float32Array(W*H);
  imgData = ctx.createImageData(W,H);
  imgU32 = new Uint32Array(imgData.data.buffer);
  requestRender();
}

function requestRender(){
  renderToken++;
  if(!renderScheduled){ renderScheduled = true; setTimeout(renderLoop, 0); }
}

function renderLoop(){
  renderScheduled = false;
  const token = renderToken;
  const maxIter = effIter();
  const julia = view.set === 'julia';
  const jre = view.jre, jim = view.jim;
  const passes = CFG.passes;
  let pi = 0, gy = 0, dirty = false;
  const t0 = performance.now();

  function slice(){
    if(token !== renderToken) return;           // cancelled — view changed
    const budgetEnd = performance.now() + CFG.sliceMs;
    while(performance.now() < budgetEnd){
      if(pi >= passes.length){                  // all passes done
        if(dirty) ctx.putImageData(imgData, 0, 0);
        lastFullPassDone = true;
        hudRender(performance.now()-t0);
        return;
      }
      const s = passes[pi];
      const gw = Math.ceil(W/s), gh = Math.ceil(H/s);
      if(gy >= gh){ pi++; gy = 0; continue; }
      /* compute one grid row of the current pass */
      const row = gy*s;
      const p = pxSize();
      const im0 = view.im + (row - H/2)*p;
      const re0 = view.re - (W/2)*p;
      const base = gy*gw;
      for(let gx=0; gx<gw; gx++){
        const re = re0 + gx*s*p;
        muBuf[base+gx] = julia ? iterate(re, im0, maxIter, true,  jre, jim)
                               : iterate(re, im0, maxIter, false, 0,   0);
      }
      /* colorize the full-res rows this grid row covers */
      colorizeRows(row, Math.min(row+s, H), s, gw);
      dirty = true; gy++;
    }
    if(dirty) ctx.putImageData(imgData, 0, 0);   // partial blit — always something on screen
    setTimeout(slice, 0);
  }
  slice();
}

/* colorize full-res rows [y0,y1) from strided grid */
function colorizeRows(y0, y1, s, gw){
  const d = imgData.data, dens = view.density/1000;
  const cyc = view.cycle;
  for(let y=y0; y<y1; y++){
    const grow = (y/s|0)*gw;
    const di = y*W*4;
    for(let x=0; x<W; x++){
      const mu = muBuf[grow + (x/s|0)];
      const o = di + x*4;
      if(mu < 0){ d[o]=0; d[o+1]=0; d[o+2]=0; }
      else{
        let idx = ((mu*dens + cyc/LUT_N) * LUT_N) | 0;
        idx = ((idx % LUT_N) + LUT_N) % LUT_N;
        const li = idx*3;
        d[o]=lut[li]; d[o+1]=lut[li+1]; d[o+2]=lut[li+2];
      }
      d[o+3]=255;
    }
  }
}
/* ============================================================
   6. NAVIGATION — zoom, pan, rect-select, history
   ============================================================ */
function pushHistory(){
  history.push({ set:view.set, re:view.re, im:view.im, scale:view.scale, jre:view.jre, jim:view.jim });
  if(history.length > 100) history.shift();
}
function goBack(){
  const s = history.pop();
  if(!s){ toast('No history'); return; }
  Object.assign(view, s);
  onViewChanged();
}
function clampDepth(){
  if(mag() > CFG.maxMag){
    view.scale = 3.0/CFG.maxMag;
    toast('Precision limit (×1e13) — zoom blocked');
  }
}
/* zoom keeping complex point (re,im) fixed at pixel (x,y) */
function zoomAt(x, y, factor){
  pushHistory();
  const [cre, cim] = px2c(x, y);
  view.scale /= factor;
  clampDepth();
  const p = pxSize();
  view.re = cre - (x - W/2)*p;
  view.im = cim - (y - H/2)*p;
  onViewChanged();
}
/* zoom so the canvas-px rect fits the view (aspect handled by expansion) */
function zoomToRect(x0, y0, x1, y1){
  if(Math.abs(x1-x0) < 8 || Math.abs(y1-y0) < 8) return;
  pushHistory();
  const p = pxSize();
  const rcx = (x0+x1)/2, rcy = (y0+y1)/2;
  const cre = view.re + (rcx - W/2)*p, cim = view.im + (rcy - H/2)*p;
  const wC = Math.abs(x1-x0)*p, hC = Math.abs(y1-y0)*p;
  view.re = cre; view.im = cim;
  view.scale = Math.max(wC/W, hC/H);   // expand to canvas aspect — rect fully contained & centered
  clampDepth();
  onViewChanged();
}
function panBy(dxPx, dyPx){
  const p = pxSize();
  view.re -= dxPx*p; view.im -= dyPx*p;
  onViewChanged();
}
function resetView(){
  pushHistory();
  Object.assign(view, { set:'mandel', re:CFG.initial.re, im:CFG.initial.im, scale:CFG.initial.scale });
  onViewChanged();
}
function onViewChanged(){
  renderToken++;                 // cancel in-flight render immediately
  requestRender();
  updateHUD();
  updateHash();
  wake();
}

/* ============================================================
   7. POINTER INTERACTION
   left-drag = rect zoom · shift/middle-drag = pan · wheel = zoom
   ============================================================ */
let drag = null;   // {x0,y0,x1,y1,mode:'rect'|'pan',button,shift}
cv.addEventListener('mousedown', e => {
  if(e.target !== cv) return;
  drag = { x0:e.clientX, y0:e.clientY, x1:e.clientX, y1:e.clientY,
           mode:(e.shiftKey || e.button===1) ? 'pan' : 'rect', moved:false };
  if(drag.mode==='pan') cv.style.cursor='grabbing';
  e.preventDefault();
});
window.addEventListener('mousemove', e => {
  hoverMove(e.clientX, e.clientY);
  if(!drag) return;
  drag.x1 = e.clientX; drag.y1 = e.clientY;
  const dx = drag.x1-drag.x0, dy = drag.y1-drag.y0;
  if(Math.abs(dx)>3 || Math.abs(dy)>3) drag.moved = true;
  if(drag.mode==='pan'){
    panBy(-(e.movementX)*dpr, -(e.movementY)*dpr);
    drag.x0 = e.clientX; drag.y0 = e.clientY;
  } else {
    drawOverlay();
  }
});
window.addEventListener('mouseup', e => {
  if(!drag) return;
  const d = drag; drag = null;
  cv.style.cursor='crosshair';
  if(d.mode==='rect' && d.moved){
    zoomToRect(Math.min(d.x0,d.x1)*dpr, Math.min(d.y0,d.y1)*dpr,
               Math.max(d.x0,d.x1)*dpr, Math.max(d.y0,d.y1)*dpr);
  } else if(d.mode==='rect' && !d.moved){
    /* plain click: pick Julia c from clicked point */
    const [re,im] = px2c(e.clientX*dpr, e.clientY*dpr);
    view.jre = re; view.jim = im;
    renderPreview(re, im);
    toast('Julia c = '+fmt(re)+', '+fmt(im)+' — press J to open');
  }
  drawOverlay();
});
cv.addEventListener('wheel', e => {
  e.preventDefault();
  const f = Math.pow(CFG.zoomSpeed, -Math.sign(e.deltaY) * Math.min(3, Math.abs(e.deltaY)/100));
  zoomAt(e.clientX*dpr, e.clientY*dpr, f);
}, { passive:false });
cv.addEventListener('contextmenu', e => e.preventDefault());
cv.addEventListener('dblclick', e => zoomAt(e.clientX*dpr, e.clientY*dpr, 4));

/* overlay: zoom rect + julia reticle */
let hover = null;   // css px
function hoverMove(x, y){
  hover = { x, y };
  schedulePreview();
  drawOverlay();
  wake();
}
function drawOverlay(){
  octx.clearRect(0,0,W,H);
  if(drag && drag.mode==='rect' && drag.moved){
    const x = Math.min(drag.x0,drag.x1)*dpr, y = Math.min(drag.y0,drag.y1)*dpr;
    const w = Math.abs(drag.x1-drag.x0)*dpr, h = Math.abs(drag.y1-drag.y0)*dpr;
    octx.save();
    octx.globalCompositeOperation = 'difference';
    octx.strokeStyle = '#fff'; octx.lineWidth = 2*dpr;
    octx.strokeRect(x, y, w, h);
    octx.fillStyle = 'rgba(255,255,255,0.08)';
    octx.fillRect(x, y, w, h);
    octx.restore();
  }
  if(hover && !drag){
    octx.save();
    octx.globalCompositeOperation = 'difference';
    octx.strokeStyle = '#fff'; octx.lineWidth = 1;
    const x = hover.x*dpr, y = hover.y*dpr, r = 9*dpr;
    octx.beginPath();
    octx.moveTo(x-r,y); octx.lineTo(x-3*dpr,y);
    octx.moveTo(x+3*dpr,y); octx.lineTo(x+r,y);
    octx.moveTo(x,y-r); octx.lineTo(x,y-3*dpr);
    octx.moveTo(x,y+3*dpr); octx.lineTo(x,y+r);
    octx.stroke();
    octx.restore();
  }
}

/* ============================================================
   8. KEYBOARD
   ============================================================ */
window.addEventListener('keydown', e => {
  if(e.target.tagName === 'INPUT' && e.target.type === 'text') return;
  wake();
  const panPx = CFG.panKeyFrac;
  switch(e.key){
    case 'ArrowLeft':  pushHistory(); view.re -= panPx*W*pxSize(); onViewChanged(); break;
    case 'ArrowRight': pushHistory(); view.re += panPx*W*pxSize(); onViewChanged(); break;
    case 'ArrowUp':    pushHistory(); view.im -= panPx*H*pxSize(); onViewChanged(); break;
    case 'ArrowDown':  pushHistory(); view.im += panPx*H*pxSize(); onViewChanged(); break;
    case '+': case '=': zoomAt(W/2, H/2, CFG.zoomSpeed*2); break;
    case '-': case '_': zoomAt(W/2, H/2, 1/(CFG.zoomSpeed*2)); break;
    case 'j': case 'J': toggleJulia(); break;
    case 'b': case 'B': goBack(); break;
    case 'r': case 'R': resetView(); break;
    case 'h': case 'H': $('help').classList.toggle('show'); break;
    case 'Escape': $('help').classList.remove('show'); break;
    default: return;
  }
  e.preventDefault();
});

function toggleJulia(){
  pushHistory();
  if(view.set === 'mandel'){
    /* use hovered c if available, else current center */
    if(hover){ const [re,im] = px2c(hover.x*dpr, hover.y*dpr); view.jre = re; view.jim = im; }
    view.set = 'julia';
    toast('Julia set for c = '+fmt(view.jre)+', '+fmt(view.jim));
  } else {
    view.set = 'mandel';
  }
  onViewChanged();
  renderPreview(view.jre, view.jim);
}

/* ============================================================
   9. JULIA LIVE PREVIEW (small canvas, other set + marker)
   ============================================================ */
let previewPending = false;
function schedulePreview(){
  if(previewPending) return;
  previewPending = true;
  requestAnimationFrame(() => {
    previewPending = false;
    if(!hover) return;
    const [re,im] = px2c(hover.x*dpr, hover.y*dpr);
    if(view.set === 'mandel'){ renderPreview(re, im); }
    else { renderMandelPreview(re, im); }
  });
}
function renderPreview(cre, cim){
  const w = CFG.previewW, h = CFG.previewH;
  const imgp = pctx.createImageData(w, h);
  const d = imgp.data;
  const p = 3.0/h;
  for(let y=0; y<h; y++){
    for(let x=0; x<w; x++){
      const zx = (x - w/2)*p, zy = (y - h/2)*p;
      const mu = iterate(zx, zy, CFG.previewIter, true, cre, cim);
      const o = (y*w + x)*4;
      if(mu < 0){ d[o]=0; d[o+1]=0; d[o+2]=0; }
      else{
        let idx = ((mu*0.03 + view.cycle/LUT_N) * LUT_N) | 0;
        idx = ((idx % LUT_N) + LUT_N) % LUT_N;
        d[o]=lut[idx*3]; d[o+1]=lut[idx*3+1]; d[o+2]=lut[idx*3+2];
      }
      d[o+3]=255;
    }
  }
  pctx.putImageData(imgp, 0, 0);
  $('previewCap').textContent = 'Julia  c = '+fmt(cre)+', '+fmt(cim);
  /* marker: crosshair at c in preview is implicit (c not in z-plane); show label only */
}
function renderMandelPreview(cre, cim){
  const w = CFG.previewW, h = CFG.previewH;
  const imgp = pctx.createImageData(w, h);
  const d = imgp.data;
  const p = 3.5/w;
  for(let y=0; y<h; y++){
    for(let x=0; x<w; x++){
      const cx = (x - w/2)*p - 0.6, cy = (y - h/2)*p;
      const mu = iterate(cx, cy, CFG.previewIter, false, 0, 0);
      const o = (y*w + x)*4;
      if(mu < 0){ d[o]=0; d[o+1]=0; d[o+2]=0; }
      else{
        let idx = ((mu*0.03 + view.cycle/LUT_N) * LUT_N) | 0;
        idx = ((idx % LUT_N) + LUT_N) % LUT_N;
        d[o]=lut[idx*3]; d[o+1]=lut[idx*3+1]; d[o+2]=lut[idx*3+2];
      }
      d[o+3]=255;
    }
  }
  /* marker at hovered c */
  const mx = (cre + 0.6)/p, my = cim/p + h/2;
  pctx.putImageData(imgp, 0, 0);
  pctx.strokeStyle = '#fff'; pctx.lineWidth = 1;
  pctx.beginPath(); pctx.arc(mx, my, 4, 0, Math.PI*2); pctx.stroke();
  $('previewCap').textContent = 'Mandelbrot — hover c marked';
}

/* ============================================================
   10. HUD / UI
   ============================================================ */
function fmt(v){
  if(Math.abs(v) < 1e-12) v = 0;
  return v.toFixed(Math.min(15, Math.max(5, 15 - Math.floor(Math.log10(Math.max(1e-15, Math.abs(v)))) )));
}
function fmtMag(m){
  if(m < 1e6) return '×'+Math.round(m).toLocaleString();
  return '×'+m.toExponential(1);
}
let lastRenderMs = 0, fpsSmooth = 60, lastFrame = performance.now();
function hudRender(ms){
  lastRenderMs = ms;
}
function tick(now){
  const dt = now - lastFrame; lastFrame = now;
  fpsSmooth = fpsSmooth*0.9 + (1000/Math.max(1,dt))*0.1;
  $('hudFps').textContent = Math.round(fpsSmooth)+' fps · last render '+Math.round(lastRenderMs)+' ms';
  requestAnimationFrame(tick);
}
function updateHUD(){
  $('hudMag').textContent = fmtMag(mag());
  $('hudCenter').textContent = fmt(view.re)+', '+fmt(view.im);
  $('hudIter').textContent = effIter()+(view.iterOverride>0?' (manual)':' (auto)');
  $('setName').textContent = view.set==='julia' ? 'Julia' : 'Mandelbrot';
  $('juliaC').textContent = 'c = '+fmt(view.jre)+', '+fmt(view.jim);
  $('btnJulia').textContent = view.set==='julia' ? 'Mandel' : 'Julia';
}
let idleTimer = 0;
function wake(){
  document.body.classList.remove('idle');
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => document.body.classList.add('idle'), CFG.idleHideMs);
}
let toastTimer = 0;
function toast(msg){
  const t = $('toast');
  t.textContent = msg; t.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(() => t.classList.remove('show'), 2600);
}

/* palette chips */
function buildChips(){
  const box = $('paletteChips'); box.innerHTML = '';
  PALETTES.forEach((p, i) => {
    const b = document.createElement('button');
    b.className = 'chip' + (i===view.pal ? ' sel' : '');
    b.title = p.name;
    const g = p.stops.map(s => s[1]).join(',');
    b.style.background = 'linear-gradient(90deg,'+g+')';
    b.onclick = () => { view.pal = i; buildLUT(); buildChips(); recolorOnly(); };
    box.appendChild(b);
  });
}
/* recolor without recomputing iterations (uses full-res muBuf) */
function recolorOnly(){
  colorizeRows(0, H, 1, W);
  ctx.putImageData(imgData, 0, 0);
}
/* NOTE: recolorOnly requires the last full pass (s=1) to have completed;
   if interrupted, fall back to a re-render. */
function safeRecolor(){
  if(muBuf && muBuf.length === W*H && lastFullPassDone) recolorOnly();
  else requestRender();
}
let lastFullPassDone = false;

/* sliders */
$('iterSlider').oninput = e => {
  view.iterOverride = +e.target.value;
  $('iterOut').textContent = view.iterOverride>0 ? view.iterOverride : 'auto';
  requestRender(); updateHUD(); updateHash();
};
$('cycleSlider').oninput = e => {
  view.cycle = +e.target.value;
  $('cycleOut').textContent = view.cycle;
  safeRecolor(); updateHash();
};
$('densSlider').oninput = e => {
  view.density = +e.target.value;
  $('densOut').textContent = (view.density/10).toFixed(1)+'%';
  safeRecolor(); updateHash();
};
$('btnBack').onclick = goBack;
$('btnReset').onclick = resetView;
$('btnJulia').onclick = toggleJulia;
$('btnHelp').onclick = () => $('help').classList.toggle('show');

/* ============================================================
   11. BOOKMARKS (localStorage) + URL hash
   ============================================================ */
const BM_KEY = 'fractal.bookmarks';
function snapshotState(){
  return { set:view.set, re:view.re, im:view.im, scale:view.scale,
           jre:view.jre, jim:view.jim, pal:view.pal,
           iter:view.iterOverride, cycle:view.cycle, density:view.density };
}
function applyState(s){
  Object.assign(view, {
    set:s.set, re:s.re, im:s.im, scale:s.scale, jre:s.jre, jim:s.jim,
    pal:s.pal||0, iterOverride:s.iter||0, cycle:s.cycle||0, density:s.density||30
  });
  $('iterSlider').value = view.iterOverride;
  $('iterOut').textContent = view.iterOverride>0 ? view.iterOverride : 'auto';
  $('cycleSlider').value = view.cycle; $('cycleOut').textContent = view.cycle;
  $('densSlider').value = view.density; $('densOut').textContent = (view.density/10).toFixed(1)+'%';
  buildLUT(); buildChips();
  onViewChanged();
}
function getBookmarks(){
  try{ return JSON.parse(localStorage.getItem(BM_KEY)) || []; }catch(_){ return []; }
}
function renderBookmarks(){
  const list = getBookmarks(), box = $('bmList');
  box.innerHTML = '';
  if(!list.length){ box.innerHTML = '<div class="empty">No bookmarks yet</div>'; return; }
  list.forEach((bm, i) => {
    const row = document.createElement('div'); row.className = 'bm';
    const b = document.createElement('button');
    b.textContent = bm.name;
    b.onclick = () => { pushHistory(); applyState(JSON.parse(JSON.stringify(bm.state))); toast('Restored "'+bm.name+'"'); };
    const del = document.createElement('button');
    del.className = 'del'; del.textContent = '×';
    del.onclick = () => { const l = getBookmarks(); l.splice(i,1); localStorage.setItem(BM_KEY, JSON.stringify(l)); renderBookmarks(); };
    row.appendChild(b); row.appendChild(del); box.appendChild(row);
  });
}
$('bmSave').onclick = () => {
  const name = $('bmName').value.trim() || ('view '+new Date().toLocaleTimeString());
  const list = getBookmarks();
  list.push({ name, state: snapshotState() });
  localStorage.setItem(BM_KEY, JSON.stringify(list));
  $('bmName').value = '';
  renderBookmarks();
  toast('Bookmark saved');
};

function updateHash(){
  try{
    const s = snapshotState();
    const enc = btoa(JSON.stringify(s)).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
    history.replaceState(null, '', '#'+enc);
  }catch(_){}
}
function loadHash(){
  if(!location.hash || location.hash.length < 2) return false;
  try{
    let b = location.hash.slice(1).replace(/-/g,'+').replace(/_/g,'/');
    while(b.length % 4) b += '=';
    applyState(JSON.parse(atob(b)));
    return true;
  }catch(_){ return false; }
}

/* ============================================================
   12. DEBUG INTERFACE (window.fractal)
   ============================================================ */
window.fractal = {
  view,
  iterationsAt(re, im, maxIter){
    const mi = maxIter || effIter();
    const mu = (view.set === 'julia')
      ? iterate(re, im, mi, true, view.jre, view.jim)
      : iterate(re, im, mi, false, 0, 0);
    return mu < 0 ? mi : mu;   // interior → exactly maxIter
  },
  setView(o){
    if(o.set) view.set = o.set;
    if('re' in o) view.re = o.re;
    if('im' in o) view.im = o.im;
    if('scale' in o) view.scale = o.scale;
    if('jre' in o) view.jre = o.jre;
    if('jim' in o) view.jim = o.jim;
    onViewChanged();
  },
  getView(){ return snapshotState(); },
  zoomToRect, resetView, goBack, toggleJulia, applyState, snapshotState,
  px2c, c2px, mag, effIter,
  bookmarks: { list:getBookmarks, save:(n)=>{ $('bmName').value=n; $('bmSave').click(); } }
};

/* ============================================================
   13. INIT
   ============================================================ */
window.addEventListener('resize', resize);
buildLUT();
buildChips();
renderBookmarks();
resize();
updateHUD();
if(!loadHash()) renderPreview(view.jre, view.jim);
wake();
requestAnimationFrame(tick);

</script>
</body>
</html>
<!-- agent-meta {"model":"stealth/ox-alpha","provider":"openrouter","persona":"full","sessionId":"3c5f314b-fa9f-4994-aa6c-abd252630978","tokensIn":439563,"tokensOut":18567,"tokensTotal":458130,"cost":0,"turns":14,"toolCalls":15,"failedToolCalls":0,"timestamp":"2026-08-21T21:22:57.130Z"} -->