← Fractal Explorer results

Fractal Explorer

Tiel Coder 35B A3B GGUF MTP · 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 38,922 bytes · SHA-256 d41676fdf874
<!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 &amp; Julia</title>
<style>
  /* ============================================================
     GLOBAL / LAYOUT
     ============================================================ */
  * { margin:0; padding:0; box-sizing:border-box; }
  html,body { width:100%; height:100%; overflow:hidden; background:#000; font-family: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; color:#e8f0ff; }
  #stage { position:fixed; inset:0; }
  canvas#main { display:block; width:100%; height:100%; cursor:crosshair; touch-action:none; }

  /* ============================================================
     OVERLAYS (reticle + selection box) — live DOM, no canvas cost
     ============================================================ */
  #reticle { position:absolute; pointer-events:none; width:26px; height:26px; margin:-13px 0 0 -13px; opacity:0; transition:opacity .15s; z-index:5; }
  #reticle.on { opacity:.9; }
  #reticle::before, #reticle::after { content:""; position:absolute; background:#9fe0ff; box-shadow:0 0 4px rgba(0,0,0,.8); }
  #reticle::before { left:12px; top:0; width:1px; height:26px; }
  #reticle::after  { top:12px; left:0; height:1px; width:26px; }
  #selbox { position:absolute; pointer-events:none; opacity:0; z-index:6; border:2px solid #fff; box-shadow:0 0 0 9999px rgba(0,0,0,.35); border-radius:2px; transition:opacity .08s; }
  #selbox.on { opacity:1; }
  #selbox .corner { position:absolute; width:8px; height:8px; border:2px solid #fff; }
  #selbox .tl{ top:-5px; left:-5px; border-right:0; border-bottom:0; }
  #selbox .tr{ top:-5px; right:-5px; border-left:0; border-bottom:0; }
  #selbox .bl{ bottom:-5px; left:-5px; border-right:0; border-top:0; }
  #selbox .br{ bottom:-5px; right:-5px; border-left:0; border-top:0; }
  #selbox .lbl { position:absolute; bottom:-22px; left:0; background:rgba(10,16,28,.85); border:1px solid rgba(160,200,255,.4); padding:1px 6px; font-size:11px; white-space:nowrap; color:#cfe4ff; }

  /* ============================================================
     GLASSY HUD
     ============================================================ */
  #hud { position:absolute; top:12px; left:12px; z-index:8; padding:12px 14px; min-width:236px; background:rgba(12,18,30,.55); backdrop-filter:blur(10px) saturate(140%); -webkit-backdrop-filter:blur(10px) saturate(140%); border:1px solid rgba(140,180,255,.18); border-radius:12px; box-shadow:0 8px 30px rgba(0,0,0,.45); transition:opacity .35s ease, transform .35s ease; user-select:none; }
  #hud.hidden { opacity:0; transform:translateY(-6px); pointer-events:none; }
  #hud h1 { font-size:12px; letter-spacing:.14em; text-transform:uppercase; font-weight:600; color:#9fd0ff; margin-bottom:8px; }
  .row { display:flex; justify-content:space-between; font-size:12px; line-height:1.7; gap:14px; }
  .row .k { color:#8fa4c0; }
  .row .v { color:#eaf3ff; font-variant-numeric:tabular-nums; text-align:right; }
  .v.accent { color:#9fe6a0; }
  #hud .sep { height:1px; background:rgba(140,180,255,.15); margin:6px 0; }

  /* ============================================================
     CONTROL PANEL (right)
     ============================================================ */
  #panel { position:absolute; top:12px; right:12px; z-index:8; width:244px; padding:14px; background:rgba(12,18,30,.55); backdrop-filter:blur(10px) saturate(140%); -webkit-backdrop-filter:blur(10px) saturate(140%); border:1px solid rgba(140,180,255,.18); border-radius:12px; box-shadow:0 8px 30px rgba(0,0,0,.45); transition:opacity .35s ease, transform .35s ease; }
  #panel.hidden { opacity:0; transform:translateX(6px); pointer-events:none; }
  #panel h2 { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:#9fd0ff; margin:2px 0 8px; }
  .seg { display:flex; gap:6px; margin-bottom:10px; }
  .seg button { flex:1; padding:6px 0; font:inherit; font-size:11px; color:#cfe0ff; background:rgba(255,255,255,.05); border:1px solid rgba(140,180,255,.22); border-radius:8px; cursor:pointer; transition:background .15s, color .15s; }
  .seg button:hover { background:rgba(255,255,255,.12); }
  .seg button.active { background:rgba(120,180,255,.28); border-color:rgba(150,200,255,.6); color:#fff; }
  .chip-row { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:6px; }
  .chip { width:26px; height:26px; border-radius:7px; border:2px solid rgba(255,255,255,.15); cursor:pointer; position:relative; transition:transform .12s, border-color .12s; }
  .chip:hover { transform:scale(1.08); }
  .chip.active { border-color:#fff; box-shadow:0 0 0 2px rgba(255,255,255,.25); }
  .ctrl { margin:10px 0; }
  .ctrl label { display:block; font-size:11px; color:#8fa4c0; margin-bottom:4px; display:flex; justify-content:space-between; }
  .ctrl label span { color:#cfe4ff; }
  input[type=range] { width:100%; accent-color:#6fb3ff; }
  .btn-row { display:flex; gap:6px; margin:8px 0; flex-wrap:wrap; }
  .btn-row button { flex:1; min-width:64px; padding:7px 4px; font:inherit; font-size:11px; color:#dce9ff; background:rgba(255,255,255,.06); border:1px solid rgba(140,180,255,.24); border-radius:8px; cursor:pointer; }
  .btn-row button:hover { background:rgba(255,255,255,.14); }
  .check { display:flex; align-items:center; gap:8px; font-size:11px; color:#b8cbe6; margin:8px 0; cursor:pointer; }
  .check input { accent-color:#6fb3ff; }
  #bookmarks { margin-top:6px; max-height:150px; overflow-y:auto; }
  .bk { display:flex; align-items:center; justify-content:space-between; gap:6px; padding:5px 6px; font-size:11px; border-radius:7px; background:rgba(255,255,255,.04); margin-bottom:4px; border:1px solid rgba(140,180,255,.12); }
  .bk span { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#cfe4ff; cursor:pointer; }
  .bk span:hover { color:#fff; }
  .bk button { background:none; border:none; color:#8fa4c0; cursor:pointer; font-size:12px; padding:0 2px; }
  .bk button:hover { color:#ff9b9b; }
  .bkadd { display:flex; gap:6px; margin-top:6px; }
  .bkadd input { flex:1; background:rgba(0,0,0,.25); border:1px solid rgba(140,180,255,.2); border-radius:7px; color:#eaf3ff; padding:5px 7px; font:inherit; font-size:11px; }
  .bkadd button { background:rgba(120,180,255,.22); border:1px solid rgba(150,200,255,.5); color:#fff; border-radius:7px; padding:5px 9px; cursor:pointer; font:inherit; font-size:11px; }
  #hint { position:absolute; bottom:12px; left:12px; z-index:8; font-size:11px; color:rgba(180,205,240,.6); background:rgba(12,18,30,.45); padding:6px 10px; border-radius:8px; border:1px solid rgba(140,180,255,.12); transition:opacity .35s; pointer-events:none; max-width:52vw; line-height:1.6; }
  #hint.hidden { opacity:0; }
  #warn { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:9; background:rgba(40,10,10,.9); border:1px solid rgba(255,120,120,.5); color:#ffc9c9; padding:12px 18px; border-radius:10px; font-size:13px; display:none; text-align:center; max-width:80vw; }
  .preview-wrap { margin-top:10px; border-top:1px solid rgba(140,180,255,.15); padding-top:10px; }
  .preview-wrap h2 { margin-bottom:6px; }
  #preview { width:100%; height:120px; border-radius:8px; border:1px solid rgba(140,180,255,.2); background:#000; display:block; }
  #previewC { font-size:11px; color:#b8cbe6; margin-top:4px; text-align:center; }
</style>
</head>
<body>
<div id="stage">
  <canvas id="main"></canvas>
  <div id="reticle"></div>
  <div id="selbox"><span class="corner tl"></span><span class="corner tr"></span><span class="corner bl"></span><span class="corner br"></span><span class="lbl"></span></div>
</div>

<div id="hud">
  <h1>Fractal Explorer</h1>
  <div class="row"><span class="k">Mode</span><span class="v" id="hudMode">Mandelbrot</span></div>
  <div class="row"><span class="k">Magnification</span><span class="v accent" id="hudMag">×1</span></div>
  <div class="row"><span class="k">Center Re</span><span class="v" id="hudRe">0</span></div>
  <div class="row"><span class="k">Center Im</span><span class="v" id="hudIm">0</span></div>
  <div class="sep"></div>
  <div class="row"><span class="k">Iterations</span><span class="v" id="hudIter">0</span></div>
  <div class="row"><span class="k">FPS</span><span class="v" id="hudFps">0</span></div>
  <div class="row"><span class="k">Pass</span><span class="v" id="hudPass">—</span></div>
</div>

<div id="panel">
  <h2>Set</h2>
  <div class="seg">
    <button id="btnMandel" class="active">Mandelbrot</button>
    <button id="btnJulia">Julia</button>
  </div>

  <h2>Palette</h2>
  <div class="chip-row" id="palettes"></div>

  <div class="ctrl">
    <label>Iterations <span id="iterVal">+0</span></label>
    <input type="range" id="iterSlider" min="0" max="8000" step="1" value="0">
  </div>
  <div class="ctrl">
    <label>Color cycle <span id="cycleVal">0</span></label>
    <input type="range" id="cycleSlider" min="0" max="4095" step="1" value="0">
  </div>
  <label class="check"><input type="checkbox" id="animCheck"> Animate color drift</label>

  <div class="btn-row">
    <button id="btnBack">◀ Back</button>
    <button id="btnReset">Reset</button>
  </div>

  <h2>Bookmarks</h2>
  <div id="bookmarks"></div>
  <div class="bkadd"><input id="bkName" placeholder="View name"><button id="bkAdd">Save</button></div>

  <div class="preview-wrap">
    <h2>Julia Preview</h2>
    <canvas id="preview"></canvas>
    <div id="previewC">hover the view</div>
  </div>
</div>

<div id="hint">
  Drag to zoom · <b>Space+drag</b> pan · <b>Wheel</b> zoom · <b>Arrows</b> pan · <b>+ / −</b> zoom · <b>J</b> Julia · <b>Click</b> open Julia · <b>H</b> hide UI
</div>

<div id="warn"></div>

<script>
/* ============================================================
   TUNABLE CONSTANTS  (edit these to trade quality / speed)
   ============================================================ */
const T = {
  ESCAPE_RADIUS2 : 256,        // |z|^2 escape threshold (smooth coloring uses log|z|)
  BASE_ITER      : 300,        // max iterations at magnification ≈ 1×
  ITER_PER_OCTAVE: 20,         // +iterations per 2× magnification
  ITER_SLIDER_MAX: 8000,       // manual iteration override max
  MAX_ITER_CAP   : 20000,      // hard cap
  MAX_MAGNIFICATION: 1e14,     // double-precision ceiling (precision degrades past this)
  ZOOM_FACTOR    : 0.0022,     // wheel sensitivity (exponential)
  KB_ZOOM        : 1.6,        // +/- zoom step multiplier
  KB_PAN_PX      : 8,          // arrow-key pan distance in px
  COARSE_DIV     : 4,          // coarse pass resolution divisor
  IDLE_MS        : 380,        // idle time before full-res refine begins
  ROWS_PER_FRAME : 20,         // full-res refine rows per animation frame (progressive)
  COLOR_LUT_SIZE : 4096,       // color lookup table size
  COLOR_SPEED    : 0.018,      // color cycles per iteration unit
  MOMENTUM_DAMP  : 0.90,       // pan momentum decay
};

const INITIAL_VIEW = { re:-0.5, im:0.0, scale:4.0 };   // complex width 4 → full Mandelbrot visible

/* ============================================================
   PALETTES  (stops over t∈[0,1); 'periodic' ⇒ seamless cyclic wrap)
   ============================================================ */
const PALETTES = {
  fire:      { periodic:false, stops:[[0,[4,0,12]],[.42,[150,18,4]],[.7,[238,120,18]],[.88,[255,214,96]],[1,[255,255,238]]] },
  ocean:     { periodic:false, stops:[[0,[2,6,20]],[.4,[8,44,120]],[.7,[14,150,200]],[.88,[120,230,255]],[1,[236,255,255]]] },
  mono:      { periodic:false, stops:[[0,[0,0,0]],[.55,[55,55,60]],[.8,[170,170,180]],[1,[255,255,255]]] },
  psychedelic:{ periodic:true,  stops:null },          // built via cosine formula
  supernova: { periodic:false, stops:[[0,[8,0,18]],[.35,[120,20,140]],[.6,[230,70,20]],[.82,[255,190,60]],[1,[255,255,255]]] },
  arctic:    { periodic:false, stops:[[0,[2,8,18]],[.45,[30,90,140]],[.7,[120,210,215]],[.88,[200,245,240]],[1,[255,255,255]]] },
};
const PALETTE_ORDER = ['fire','ocean','mono','psychedelic','supernova','arctic'];

function cosinePalette(v){
  // Inigo Quilez style cyclic palette; v∈[0,1) wraps seamlessly
  const a=0.5, b=0.5, p=6.283185307179586;
  const r = a + b*Math.cos(p*(1.0*v + 0.00));
  const g = a + b*Math.cos(p*(1.0*v + 0.66));
  const bl= a + b*Math.cos(p*(1.0*v + 0.33));
  return [r,g,bl];
}

function sampleStops(stops, t){
  const n = stops.length - 1;
  const ft = t * n;
  const i = Math.min(Math.floor(ft), n-1);
  const f = ft - i;
  const s = f*f*(3-2*f);              // smoothstep → no visible bands
  const a = stops[i], b = stops[i+1];
  return [ a[1][0]+(b[1][0]-a[1][0])*s, a[1][1]+(b[1][1]-a[1][1])*s, a[1][2]+(b[1][2]-a[1][2])*s ];
}

function buildLUT(name){
  const L = T.COLOR_LUT_SIZE;
  const local = new Array(L*3);
  const pal = PALETTES[name];
  // Escape-count ramp: LUT index 0 (fast escape) → dark, index L-1 → brightest stop.
  // The brightest stop is reserved for the very end so the set boundary glows.
  for(let i=0;i<L;i++){
    const v = i / L;                    // [0,1)
    let c;
    if(pal.periodic){
      c = cosinePalette(v);              // seamless cyclic (period 2π)
    } else {
      c = sampleStops(pal.stops, v);    // ramp: index 0 (dark) → index L-1 (brightest)
    }
    const bright = Math.min(1, v*0.95);              // ramp brightness from dark → near-bright
    const r = Math.min(255, Math.round(c[0]*255*bright));
    const g = Math.min(255, Math.round(c[1]*255*bright));
    const bl= Math.min(255, Math.round(c[2]*255*bright));
    local[i*3]   = r;
    local[i*3+1] = g;
    local[i*3+2] = bl;
  }
  return local;
}

/* ============================================================
   VIEW STATE
   ============================================================ */
const view     = Object.assign({}, INITIAL_VIEW);
const juliaC   = { re:-0.5, im:0.0 };
let mode       = 'mandel';            // 'mandel' | 'julia'
let followCursor = true;              // in Julia mode, move cursor ⇒ new c
let paletteName= 'fire';
let iterOffset = 0;
let cycleOffset= 0;
let animating  = false;
let curMaxIter = T.BASE_ITER;
let lut = buildLUT(paletteName);

const history = [];                    // zoom history stack

/* ============================================================
   CANVAS / BUFFER SETUP
   ============================================================ */
const canvas   = document.getElementById('main');
const ctx      = canvas.getContext('2d', { alpha:false });
let W = 0, H = 0;                      // display (backing) size
let coarseW = 0, coarseH = 0;
let coarseBuf, fullBuf;
let coarseIA, coarseScr, fullIA, fullScr;

function makeScratch(w,h){
  const c = document.createElement('canvas');
  c.width = w; c.height = h;
  c.getContext('2d').drawImage(canvas,0,0); // warm
  return c;
}

function resize(){
  const r = canvas.getBoundingClientRect();
  W = Math.max(1, Math.round(r.width));
  H = Math.max(1, Math.round(r.height));
  canvas.width = W; canvas.height = H;
  coarseW = Math.max(1, Math.floor(W / T.COARSE_DIV));
  coarseH = Math.max(1, Math.floor(H / T.COARSE_DIV));
  coarseBuf = new Float32Array(coarseW*coarseH);
  fullBuf   = new Float32Array(W*H);
  coarseScr = makeScratch(coarseW,coarseH); coarseIA = coarseScr.getContext('2d').createImageData(coarseW,coarseH);
  fullScr   = makeScratch(W,H);            fullIA   = fullScr.getContext('2d').createImageData(W,H);
  viewDirty = true;
}

/* ============================================================
   COORDINATE MAPPING
   uniform px→complex scale ⇒ shapes undistorted, aspect = W:H
   ============================================================ */
function complexHeight(){ return view.scale * (H / W); }
function pxToComplex(px, py){
  return {
    re: view.re + ((px + 0.5) / W - 0.5) * view.scale,
    im: view.im + ((py + 0.5) / H - 0.5) * complexHeight()
  };
}

/* ============================================================
   ITERATION MATH  (allocation-free inner loop, smooth coloring)
   ============================================================ */
function computeMaxIter(){
  const mag = (INITIAL_VIEW.scale / view.scale);
  const fromZoom = Math.log2(Math.max(1, mag)) * T.ITER_PER_OCTAVE;
  let m = T.BASE_ITER + Math.round(fromZoom) + iterOffset;
  m = Math.max(T.BASE_ITER, Math.min(m, T.MAX_ITER_CAP));
  curMaxIter = m;
  return m;
}

// compute iterations for rectangle [x0,x1)×[y0,y1) into buf (row-major, W columns)
function computeRect(buf, W, H, x0, x1, y0, y1, cRe, cIm, reC, imC, scale, maxIter){
  const aspect = scale * (H / W);
  const invW = 1 / W, invH = 1 / H;
  const idx = y0 * W + x0;
  let o = idx;
  for(let y = y0; y < y1; y++){
    const cy = imC + ((y + 0.5) * invH - 0.5) * aspect;
    for(let x = x0; x < x1; x++){
      const cx = reC + ((x + 0.5) * invW - 0.5) * scale;
      let zr = cx, zi = cy;           // Mandelbrot: start z=c; Julia handled by caller
      let n = 0;
      while(n < maxIter){
        const zr2 = zr*zr - zi*zi + cx;
        zi = 2*zr*zi + cy;
        zr = zr2;
        const m2 = zr*zr + zi*zi;
        if(m2 > 4.0) break;
        n++;
      }
      if(n < maxIter){
        const m2 = zr*zr + zi*zi;
        const logZ = 0.5 * Math.log(m2);
        buf[o] = n + 1 - (Math.log(logZ / Math.LN2) / Math.LN2);  // smooth fractional count
      } else {
        buf[o] = maxIter;              // interior → black
      }
      o++;
    }
  }
}

// Public debug helper: smooth iteration count at a complex point.
function iterationsAt(re, im){
  const maxIter = computeMaxIter();
  if(mode === 'mandel'){
    computeRect(new Float32Array(1), 1, 1, 0, 1, 0, 1, re, im, re, im, 1, maxIter);
    // recompute inline to avoid buffer confusion:
    let zr=0,zi=0,n=0,cR=re,cI=im;
    while(n < maxIter){
      const zr2 = zr*zr - zi*zi + cR;
      zi = 2*zr*zi + cI; zr = zr2;
      if(zr*zr+zi*zi > T.ESCAPE_RADIUS2) break;
      n++;
    }
    if(n < maxIter){ const lz=0.5*Math.log(zr*zr+zi*zi); return n + 1 - (Math.log(lz/Math.LN2)/Math.LN2); }
    return maxIter;
  } else {
    let zr=re,zi=im,n=0,cR=juliaC.re,cI=juliaC.im;
    while(n < maxIter){
      const zr2 = zr*zr - zi*zi + cR;
      zi = 2*zr*zi + cI; zr = zr2;
      if(zr*zr+zi*zi > T.ESCAPE_RADIUS2) break;
      n++;
    }
    if(n < maxIter){ const lz=0.5*Math.log(zr*zr+zi*zi); return n + 1 - (Math.log(lz/Math.LN2)/Math.LN2); }
    return maxIter;
  }
}

/* ============================================================
   COLORIZE  (colorize-pass only; cycle offset here ⇒ no recompute)
   ============================================================ */
function colorize(buf, W, H, IA){
  const data = IA.data;
  const L = lut.length, co = cycleOffset, mi = curMaxIter;
  let i = 0, p = 0;
  const lim = W * H;
  while(p < lim){
    const count = buf[p++];
    let o;
    if(count < mi){
      const v = count / mi;                 // normalize to [0,1) so full LUT range is used
      o = ((v * L) | 0 + co);
      if(o >= L) o -= L;                     // co < L so single subtraction suffices
    }
    data[i++] = lut[o]; data[i++] = lut[o+1]; data[i++] = lut[o+2]; data[i++] = 255;
  }
}

/* ============================================================
   RENDER PIPELINE  (progressive + token cancellation)
   ============================================================ */
let token = 0;
let viewDirty = true;
let refining = false;
let refineTk = -1;
let refineRow = 0;
let refiningFull = false;

function blit(offScr, offW, offH){
  ctx.drawImage(offScr, 0, 0, W, H);
}

function renderAt(resW, resH, buf, IA, offScr, isFull, tk){
  if(tk !== token) return false;          // cancelled
  computeMaxIter();
  const cRe = mode==='mandel' ? 0 : juliaC.re;
  const cIm = mode==='mandel' ? 0 : juliaC.im;
  computeRect(buf, resW, resH, 0, resW, 0, resH, cRe, cIm, view.re, view.im, view.scale, curMaxIter);
  colorize(buf, resW, resH, IA);
  offScr.getContext('2d').putImageData(IA, 0, 0);
  blit(offScr, resW, resH);
  return true;
}

function startRefine(tk){
  if(refining && refineTk === tk) return;
  stopRefine();
  refining = true; refineTk = tk; refineRow = 0; refiningFull = true;
  hudPass('full');
}
function stopRefine(){
  refining = false; refineTk = -1; refineRow = 0; refiningFull = false;
}
function advanceRefine(){
  if(refineTk !== token){ stopRefine(); return; }
  const end = Math.min(H, refineRow + T.ROWS_PER_FRAME);
  const cRe = mode==='mandel' ? 0 : juliaC.re;
  const cIm = mode==='mandel' ? 0 : juliaC.im;
  computeRect(fullBuf, W, H, 0, W, refineRow, end, cRe, cIm, view.re, view.im, view.scale, curMaxIter);
  refineRow = end;
  if(refineRow >= H){
    colorize(fullBuf, W, H, fullIA);
    if(token === refineTk){
      fullScr.getContext('2d').putImageData(fullIA, 0, 0);
      blit(fullScr, W, H);
    }
    stopRefine();
  }
}

/* ============================================================
   VIEW CHANGES  (with history)
   ============================================================ */
function applyView(re, im, scale, remember){
  if(scale > INITIAL_VIEW.scale) scale = INITIAL_VIEW.scale;
  if(scale < INITIAL_VIEW.scale / T.MAX_MAGNIFICATION){
    scale = INITIAL_VIEW.scale / T.MAX_MAGNIFICATION;
    showWarn('Precision limit reached — zoom capped at ~×' +
      T.MAX_MAGNIFICATION.toExponential(1) + '<br>Doubles lose precision past this point.');
  } else hideWarn();
  if(remember){ history.push(Object.assign({}, view)); if(history.length>60) history.shift(); }
  view.re = re; view.im = im; view.scale = scale;
  viewDirty = true;
  lastInteraction = performance.now();
  schedule();
}

function zoomToWorld(px, py, factor){
  const c = pxToComplex(px, py);
  const ns = view.scale / factor;
  // keep cursor-fixed: new center so c maps to same px
  const nr = c.re + (view.re - c.re) / factor;
  const ni = c.im + (view.im - c.im) / factor;
  applyView(nr, ni, ns, true);
}

function panByPixels(dxpx, dpy){
  view.re -= (dxpx / W) * view.scale;
  view.im -= (dpy / H) * complexHeight();
  viewDirty = true;
  lastInteraction = performance.now();
  schedule();
}

function doRectZoom(a, b){   // a,b = px corners
  const ca = pxToComplex(a.x, a.y), cb = pxToComplex(b.x, b.y);
  const newRe = (ca.re + cb.re) / 2;
  const newIm = (ca.im + cb.im) / 2;
  const newScale = Math.abs(b.x - a.x) / W * view.scale;   // width-mapped (exact framing)
  applyView(newRe, newIm, newScale, true);
}

function centerViewOn(re, im){
  applyView(re, im, view.scale, false);
}

/* ============================================================
   INTERACTION STATE
   ============================================================ */
let dragging = false, spacePanning = false;
let moved = false, selStart = null, selCur = null;
let lastPan = null, panVel = {x:0,y:0}, momentum = false;
let pointerIdle = true, lastMoveT = 0, mx = 0, my = 0;
let lastInteraction = performance.now();
let previewDirty = true, hoveredC = {re:INITIAL_VIEW.re, im:0};
let hudForced = false, hudHidden = false;

const reticle = document.getElementById('reticle');
const selbox  = document.getElementById('selbox');
const selLbl  = selbox.querySelector('.lbl');
const warnEl  = document.getElementById('warn');
function showWarn(html){ warnEl.innerHTML = html; warnEl.style.display = 'block'; }
function hideWarn(){ warnEl.style.display = 'none'; }

function schedule(){ if(!rafOn){ rafOn = true; requestAnimationFrame(loop); } }

canvas.addEventListener('mousedown', e => {
  if(e.button !== 0) return;
  const r = canvas.getBoundingClientRect();
  const sx = e.clientX - r.left, sy = e.clientY - r.top;
  dragging = true; moved = false; spacePanning = (e.code === 'Space') || _space;
  selStart = {x:sx, y:sy}; lastPan = {x:sx, y:sy}; panVel = {x:0,y:0}; momentum = false;
  lastMoveT = performance.now(); pointerIdle = false;
});

window.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mx = e.clientX - r.left; my = e.clientY - r.top;
  // reticle + hovered c
  reticle.classList.add('on');
  reticle.style.left = mx + 'px'; reticle.style.top = my + 'px';
  hoveredC = pxToComplex(mx, my);
  previewDirty = true;
  if(dragging){
    const dx = mx - selStart.x, dy = my - selStart.y;
    if(Math.abs(dx) + Math.abs(dy) > 3) moved = true;
    if(spacePanning){
      const now = performance.now(); const dt = Math.max(1, now - lastMoveT);
      const ndx = mx - lastPan.x, ndy = my - lastPan.y;
      panVel = { x: ndx / dt * 16, y: ndy / dt * 16 };
      panByPixels(-ndx, -ndy);
      lastPan = {x:mx, y:my}; lastMoveT = now;
      selbox.classList.remove('on');
    } else {
      updateSelBox();
    }
  }
});

window.addEventListener('mouseup', e => {
  if(!dragging) return;
  dragging = false;
  reticle.classList.remove('on');
  if(spacePanning){ momentum = true; schedule(); return; }
  selbox.classList.remove('on');
  if(moved) doRectZoom(selStart, {x:mx, y:my});
  else openJuliaAt(mx, my);
  pointerIdle = true;
});

function updateSelBox(){
  const x = Math.min(selStart.x, mx), y = Math.min(selStart.y, my);
  const w = Math.abs(mx - selStart.x), h = Math.abs(my - selStart.y);
  selbox.style.left = x+'px'; selbox.style.top = y+'px';
  selbox.style.width = w+'px'; selbox.style.height = h+'px';
  selbox.classList.add('on');
  const ca = pxToComplex(x, y), cb = pxToComplex(x + w, y + h);
  const cw = Math.abs(cb.re - ca.re), ch = Math.abs(cb.im - ca.im);
  selLbl.textContent = cw.toExponential(2)+' × '+ch.toExponential(2);
}

// momentum pan loop
function momentumStep(){
  if(!momentum) return;
  const ex = Math.abs(panVel.x), ey = Math.abs(panVel.y);
  if(ex < 0.05 && ey < 0.05){ momentum = false; return; }
  panByPixels(panVel.x, panVel.y);
  panVel.x *= T.MOMENTUM_DAMP; panVel.y *= T.MOMENTUM_DAMP;
  schedule();
}

function openJuliaAt(px, py){
  const c = pxToComplex(px, py);
  juliaC.re = c.re; juliaC.im = c.im;
  mode = 'julia'; followCursor = true;
  applyView(c.re, c.im, 3.0, false);
  syncModeButtons();
}

/* ============================================================
   WHEEL + KEYBOARD
   ============================================================ */
canvas.addEventListener('wheel', e => {
  e.preventDefault();
  const r = canvas.getBoundingClientRect();
  const px = e.clientX - r.left, py = e.clientY - r.top;
  const factor = Math.exp(-e.deltaY * T.ZOOM_FACTOR);
  zoomToWorld(px, py, factor);
}, { passive:false });

let _space = false;
window.addEventListener('keydown', e => {
  if(e.target.tagName === 'INPUT') return;
  const k = e.key;
  if(k === ' '){ _space = true; e.preventDefault(); return; }
  if(k === 'h' || k === 'H'){ hudForced = !hudForced; updateHudVisibility(); return; }
  if(k === 'j' || k === 'J'){ toggleMode(); return; }
  let handled = true;
  if(k === 'ArrowLeft')  panByPixels( T.KB_PAN_PX, 0);
  else if(k === 'ArrowRight') panByPixels(-T.KB_PAN_PX, 0);
  else if(k === 'ArrowUp')    panByPixels(0, T.KB_PAN_PX);
  else if(k === 'ArrowDown')  panByPixels(0, -T.KB_PAN_PX);
  else if(k === '+' || k === '=') zoomToWorld(W/2, H/2, T.KB_ZOOM);
  else if(k === '-' || k === '_') zoomToWorld(W/2, H/2, 1/T.KB_ZOOM);
  else handled = false;
  if(handled) e.preventDefault();
});
window.addEventListener('keyup', e => { if(e.key === ' ') _space = false; });

canvas.addEventListener('dblclick', () => { zoomToWorld(W/2, H/2, 3); });

function toggleMode(){
  if(mode === 'mandel'){
    mode = 'julia'; followCursor = true;
    juliaC.re = view.re; juliaC.im = view.im;
    applyView(juliaC.re, juliaC.im, 3.0, false);
  } else { mode = 'mandel'; }
  syncModeButtons();
}

/* ============================================================
   MAIN LOOP  (progressive render + cancellation + HUD + fps)
   ============================================================ */
let rafOn = false, rafId = 0, lastFrame = 0, fps = 0;
const hud = document.getElementById('hud');
const panel = document.getElementById('panel');
const hint = document.getElementById('hint');

function updateHudVisibility(){
  const show = hudForced;
  hud.classList.toggle('hidden', !show);
  panel.classList.toggle('hidden', !show);
  hint.classList.toggle('hidden', !show);
}

function hudPass(s){ document.getElementById('hudPass').textContent = s; }

function loop(now){
  rafId = requestAnimationFrame(loop);
  if(lastFrame > 0) fps = fps*0.9 + (1000 / Math.max(0.01, now - lastFrame))*0.1;
  lastFrame = now;

  const idle = (now - lastInteraction) > T.IDLE_MS;
  if(!hudForced){
    const hide = idle && momentum === false;
    hud.classList.toggle('hidden', hide);
    panel.classList.toggle('hidden', hide);
    hint.classList.toggle('hidden', hide);
  }

  momentumStep();

  // live Julia preview (mandel mode, pointer idle) — colorize-only cost on small canvas
  if(mode === 'mandel' && pointerIdle && previewDirty){
    renderPreview();
    previewDirty = false;
  }

  if(viewDirty){
    viewDirty = false;
    token++;
    const tk = token;
    computeMaxIter();
    renderAt(coarseW, coarseH, coarseBuf, coarseIA, coarseScr, false, tk); // fast coarse
    if(idle) startRefine(tk); else stopRefine();
  } else if(refining){
    if(refineTk === token) advanceRefine();
    else { stopRefine(); if(idle) startRefine(token); }
  }

  if(animating && idle){
    cycleOffset = (cycleOffset + 1) % lut.length;
    document.getElementById('cycleSlider').value = cycleOffset;
    document.getElementById('cycleVal').textContent = cycleOffset;
    // force colorize-only redraw
    if(!refining){ computeMaxIter(); colorize(curMaxIter>0?fullBuf:coarseBuf, (refining?W:coarseW),(refining?H:coarseH), refining?fullIA:coarseIA); (refining?fullScr:coarseScr).getContext('2d').putImageData(refining?fullIA:coarseIA,0,0); blit(refining?fullScr:coarseScr, refining?W:coarseW, refining?H:coarseH); }
  }

  updateHUD();
}

/* ============================================================
   JULIA PREVIEW PANEL
   ============================================================ */
const pvCanvas = document.getElementById('preview');
const pvCtx = pvCanvas.getContext('2d', { alpha:false });
const PV = 200;
pvCanvas.width = PV; pvCanvas.height = PV;
let pvBuf = new Float32Array(PV*PV);
let pvIA = pvCtx.createImageData(PV, PV);
let pvScr = makeScratch(PV, PV);
let pvMax = T.BASE_ITER;

function renderPreview(){
  pvMax = computeMaxIter();
  computeRect(pvBuf, PV, PV, 0, PV, 0, PV, juliaC.re, juliaC.im, hoveredC.re, hoveredC.im, 3.0, pvMax);
  colorize(pvBuf, PV, PV, pvIA);
  pvScr.getContext('2d').putImageData(pvIA, 0, 0);
  pvCtx.drawImage(pvScr, 0, 0, PV, PV);
  document.getElementById('previewC').textContent =
    'c = ' + juliaC.re.toFixed(5) + ' ' + (juliaC.im>=0?'+':'−') + ' ' + Math.abs(juliaC.im).toFixed(5) + 'i';
}

/* ============================================================
   HUD UPDATE
   ============================================================ */
function updateHUD(){
  document.getElementById('hudMode').textContent = mode==='mandel' ? 'Mandelbrot' : 'Julia';
  document.getElementById('hudMag').textContent = '×' + (INITIAL_VIEW.scale / view.scale).toExponential(2);
  document.getElementById('hudRe').textContent = view.re.toExponential(5);
  document.getElementById('hudIm').textContent = view.im.toExponential(3);
  document.getElementById('hudIter').textContent = curMaxIter;
  document.getElementById('hudFps').textContent = fps.toFixed(0);
}

/* ============================================================
   UI WIRING  (palettes, sliders, buttons, bookmarks)
   ============================================================ */
function syncModeButtons(){
  document.getElementById('btnMandel').classList.toggle('active', mode==='mandel');
  document.getElementById('btnJulia').classList.toggle('active', mode==='julia');
}

// palette chips
const chipRow = document.getElementById('palettes');
const chips = {};
PALETTE_ORDER.forEach(name => {
  const el = document.createElement('div');
  el.className = 'chip'; el.dataset.name = name;
  el.title = name;
  el.style.background = palettePreviewBg(name);
  el.onclick = () => setPalette(name);
  chipRow.appendChild(el); chips[name] = el;
});
function palettePreviewBg(name){
  const p = PALETTES[name];
  if(p.periodic){
    // derive a representative gradient
    const stops=[];
    for(let i=0;i<5;i++){ const c=cosinePalette(i/5); stops.push(`rgb(${c[0]*255|0},${c[1]*255|0},${c[2]*255|0})`); }
    return `linear-gradient(90deg,${stops.join(',')})`;
  }
  const s = p.stops;
  const grad = s.map(([t,col])=>`${col.map(x=>x|0).join(',')} ${Math.round(t*100)}%`).join(',');
  return `linear-gradient(90deg,rgb(${grad}))`;
}
function setPalette(name){
  paletteName = name;
  lut = buildLUT(name);
  PALETTE_ORDER.forEach(n => chips[n].classList.toggle('active', n===name));
  viewDirty = true;
  saveHash();
}

document.getElementById('btnMandel').onclick = () => { if(mode==='julia'){ mode='mandel'; syncModeButtons(); viewDirty=true; } };
document.getElementById('btnJulia').onclick  = () => toggleMode();
document.getElementById('btnReset').onclick  = () => applyView(INITIAL_VIEW.re, INITIAL_VIEW.im, INITIAL_VIEW.scale, false);
document.getElementById('btnBack').onclick   = () => { if(history.length){ const p = history.pop(); applyView(p.re,p.im,p.scale,false); } };

document.getElementById('iterSlider').addEventListener('input', e => {
  iterOffset = +e.target.value;
  document.getElementById('iterVal').textContent = (iterOffset>=0?'+':'') + iterOffset;
  computeMaxIter(); viewDirty = true;
});
document.getElementById('cycleSlider').addEventListener('input', e => {
  cycleOffset = +e.target.value;
  document.getElementById('cycleVal').textContent = cycleOffset;
  viewDirty = true;
});
document.getElementById('animCheck').addEventListener('change', e => { animating = e.target.checked; });

/* ---- Bookmarks (localStorage) ---- */
const BK_KEY = 'fractal-explorer-bookmarks';
function loadBookmarks(){
  try { return JSON.parse(localStorage.getItem(BK_KEY)) || {}; } catch(e){ return {}; }
}
function saveBookmarks(b){
  try { localStorage.setItem(BK_KEY, JSON.stringify(b)); } catch(e){}
}
function renderBookmarks(){
  const box = document.getElementById('bookmarks');
  const b = loadBookmarks();
  box.innerHTML = '';
  const keys = Object.keys(b);
  if(!keys.length){ box.innerHTML = '<div style="font-size:11px;color:#6f82a0;padding:4px">no bookmarks yet</div>'; }
  keys.forEach(name => {
    const row = document.createElement('div'); row.className='bk';
    const s = document.createElement('span'); s.textContent = name;
    s.onclick = () => restoreBookmark(b[name]);
    const d = document.createElement('button'); d.textContent='✕';
    d.onclick = () => { const b2=loadBookmarks(); delete b2[name]; saveBookmarks(b2); renderBookmarks(); };
    row.appendChild(s); row.appendChild(d); box.appendChild(row);
  });
}
function currentBookmarkData(name){
  return { name, mode, re:view.re, im:view.im, scale:view.scale, palette:paletteName,
           iterOffset, juliaC:Object.assign({}, juliaC), followCursor };
}
function restoreBookmark(d){
  mode = d.mode || 'mandel';
  view.re = d.re; view.im = d.im; view.scale = d.scale;
  if(d.juliaC){ juliaC.re = d.juliaC.re; juliaC.im = d.juliaC.im; }
  if(d.palette) setPalette(d.palette);
  iterOffset = d.iterOffset || 0;
  document.getElementById('iterSlider').value = iterOffset;
  document.getElementById('iterVal').textContent = (iterOffset>=0?'+':'')+iterOffset;
  followCursor = d.followCursor !== undefined ? d.followCursor : true;
  syncModeButtons();
  viewDirty = true;
  saveHash();
}
document.getElementById('bkAdd').onclick = () => {
  const inp = document.getElementById('bkName');
  let name = inp.value.trim();
  if(!name) name = 'View ' + (Object.keys(loadBookmarks()).length + 1);
  const b = loadBookmarks();
  b[name] = currentBookmarkData(name);
  saveBookmarks(b);
  inp.value = '';
  renderBookmarks();
};

/* ---- URL-hash shareable state ---- */
function encodeState(){
  const s = JSON.stringify({
    m:mode, re:view.re, im:view.im, scale:view.scale,
    pal:paletteName, io:iterOffset, jc:juliaC, fc:followCursor
  });
  return '#' + btoa(unescape(encodeURIComponent(s)));
}
function saveHash(){
  historyDebounce = clearTimeout ? historyDebounce : historyDebounce;
  if(historyTimer) clearTimeout(historyTimer);
  historyTimer = setTimeout(() => { history.replaceState(null, '', encodeState()); }, 250);
}
let historyTimer = null, historyDebounce = null;
function loadHash(){
  if(!location.hash) return false;
  try {
    const s = decodeURIComponent(escape(atob(location.hash.slice(1))));
    const d = JSON.parse(s);
    mode = d.m || 'mandel';
    view.re = d.re; view.im = d.im; view.scale = d.scale;
    juliaC.re = d.jc ? d.jc.re : -0.5; juliaC.im = d.jc ? d.jc.im : 0;
    followCursor = d.fc !== undefined ? d.fc : true;
    if(d.pal) setPalette(d.pal);
    iterOffset = d.io || 0;
    document.getElementById('iterSlider').value = iterOffset;
    document.getElementById('iterVal').textContent = (iterOffset>=0?'+':'')+iterOffset;
    syncModeButtons();
    return true;
  } catch(e){ return false; }
}

/* ============================================================
   INIT
   ============================================================ */
window.addEventListener('resize', resize);
window.addEventListener('blur', () => { dragging=false; momentum=false; });
document.addEventListener('contextmenu', e => { if(e.target===canvas) e.preventDefault(); });

resize();
if(!loadHash()){ computeMaxIter(); }
renderBookmarks();
syncModeButtons();
// initial coarse+refine
token++; viewDirty = true;
requestAnimationFrame(loop);

/* ============================================================
   DEBUG / PROBE API  (exposed on window)
   ============================================================ */
window.fractal = {
  iterationsAt,
  setView: (o) => { applyView(o.re, o.im, o.scale, false); },
  getState: () => ({ mode, re:view.re, im:view.im, scale:view.scale, palette:paletteName,
                     iterOffset, juliaC:Object.assign({}, juliaC), curMaxIter }),
  zoomBy: (factor, px, py) => zoomToWorld(px||W/2, py||H/2, factor),
  setPalette,
  setIterations: (n) => { iterOffset = n; document.getElementById('iterSlider').value=n; document.getElementById('iterVal').textContent=n; computeMaxIter(); viewDirty=true; },
  restoreBookmark,
  saveBookmark: (name) => { const b = loadBookmarks(); b[name] = currentBookmarkData(name); saveBookmarks(b); renderBookmarks(); return b[name]; },
  _T: T,
};
</script>
</body>
</html>
<!-- agent-meta {"model":"peculiar-ragdoll/Tiel-Coder-35B-A3B-GGUF-MTP","provider":"brigid","persona":"full","sessionId":"e0c80179-e1e5-4eb6-880d-e641f50fed1f","tokensIn":20838761,"tokensOut":218690,"tokensTotal":21057451,"cost":0,"turns":193,"toolCalls":192,"failedToolCalls":2,"timestamp":"2026-09-04T19:00:47.082Z"} -->