Fractal Explorer
muse spark 1.3 contributor · full

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 36,350 bytes · SHA-256 6ed789b8fcfd
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
/* ============ BASE ============ */
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#000;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#e8ecf1}
#stage{position:fixed;inset:0;background:#000}
#main{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair;image-rendering:auto}
#main.panMode{cursor:grab}#main.panMode:active{cursor:grabbing}
/* progress */
#progress{position:fixed;top:0;left:0;height:2px;width:0%;background:linear-gradient(90deg,#22d3ee,#a78bfa,#fb7185);z-index:40;transition:width .1s linear;box-shadow:0 0 8px #22d3ee}
/* ============ HUD ============ */
#hud{position:fixed;top:12px;left:12px;z-index:30;background:rgba(10,14,22,.55);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.12);border-radius:12px;padding:10px 14px;min-width:250px;transition:opacity .6s;font-size:12px;line-height:1.55;box-shadow:0 8px 30px rgba(0,0,0,.5)}
#hud.hidden{opacity:0;pointer-events:none}
#hud .mag{font-size:20px;font-weight:700;letter-spacing:.5px;color:#fff}
#hud .mag small{font-size:11px;color:#7dd3fc;font-weight:400}
#hud .row{display:flex;justify-content:space-between;gap:12px;color:#aeb9c7}
#hud .row b{color:#e8ecf1;font-weight:600}
#hud .modeTag{display:inline-block;font-size:10px;padding:1px 8px;border-radius:20px;background:rgba(125,211,252,.15);border:1px solid rgba(125,211,252,.4);color:#7dd3fc;margin-left:6px;vertical-align:middle}
/* ============ CONTROL PANEL ============ */
#panel{position:fixed;top:12px;right:12px;z-index:30;width:264px;background:rgba(10,14,22,.6);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:12px;transition:opacity .6s,transform .3s;font-size:12px;box-shadow:0 8px 30px rgba(0,0,0,.5);max-height:calc(100vh - 24px);overflow-y:auto}
#panel.hidden{opacity:0;pointer-events:none;transform:translateY(-8px)}
#panel h3{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#7d8aa0;margin:10px 0 6px}
#panel h3:first-child{margin-top:0}
.btnrow{display:flex;flex-wrap:wrap;gap:6px}
button{font:inherit;font-size:11px;color:#e8ecf1;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);border-radius:8px;padding:6px 10px;cursor:pointer;transition:background .15s,transform .1s}
button:hover{background:rgba(255,255,255,.16)}
button:active{transform:scale(.96)}
button.active{background:rgba(34,211,238,.25);border-color:#22d3ee;color:#fff}
button:focus-visible{outline:2px solid #22d3ee;outline-offset:2px}
#swatches{display:flex;gap:6px;flex-wrap:wrap}
.swatch{width:40px;height:26px;border-radius:7px;border:2px solid transparent;cursor:pointer;padding:0}
.swatch.active{border-color:#fff;box-shadow:0 0 10px rgba(255,255,255,.5)}
.swatch:focus-visible{outline:2px solid #fff}
label.sl{display:block;margin:6px 0 2px;color:#aeb9c7;font-size:11px}
label.sl output{float:right;color:#fff}
input[type=range]{width:100%;accent-color:#22d3ee}
.checkline{display:flex;align-items:center;gap:6px;margin:4px 0;color:#aeb9c7}
input[type=text]{width:100%;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;padding:6px 8px;font:inherit;font-size:11px}
#bookmarks{margin-top:6px;display:flex;flex-direction:column;gap:4px}
.bm{display:flex;align-items:center;gap:6px;background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:5px 8px}
.bm span{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;color:#cfe3ff}
.bm span:hover{text-decoration:underline}
.bm button{padding:2px 7px;font-size:10px}
#help{color:#7d8aa0;font-size:10.5px;line-height:1.6;margin-top:8px}
#help kbd{background:rgba(255,255,255,.12);border-radius:4px;padding:0 5px;color:#fff;font-size:10px}
/* ============ ZOOM BOX ============ */
#zoombox{position:fixed;z-index:25;display:none;pointer-events:none;mix-blend-mode:difference;border:1.5px solid #fff;box-shadow:0 0 0 1px #000,0 0 18px rgba(255,255,255,.35) inset}
#zoombox .corner{position:absolute;width:10px;height:10px;border:2px solid #fff}
#toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(20px);background:rgba(20,10,10,.85);border:1px solid rgba(251,113,133,.6);color:#fecdd3;padding:9px 18px;border-radius:10px;font-size:12px;z-index:50;opacity:0;transition:all .3s;pointer-events:none;max-width:80vw;text-align:center}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
/* ============ JULIA PREVIEW PIP ============ */
#pip{position:fixed;right:12px;bottom:12px;z-index:30;width:248px;background:rgba(10,14,22,.65);backdrop-filter:blur(14px);border:1px solid rgba(255,255,255,.14);border-radius:12px;padding:8px;display:none;box-shadow:0 8px 30px rgba(0,0,0,.6)}
#pip.show{display:block}
#pip canvas{width:100%;border-radius:8px;display:block;cursor:crosshair}
#pip .cap{display:flex;justify-content:space-between;font-size:10.5px;color:#aeb9c7;margin-top:5px}
#pip .cap b{color:#7dd3fc}
#pipHint{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:20;font-size:11px;color:rgba(255,255,255,.75);background:rgba(0,0,0,.45);padding:5px 14px;border-radius:20px;border:1px solid rgba(255,255,255,.15);display:none;pointer-events:none}
#pipHint.show{display:block}
#toggleUI{position:fixed;bottom:12px;left:12px;z-index:31;opacity:.6}
#toggleUI:hover{opacity:1}
@media(max-width:700px){#panel{width:210px}#hud{min-width:0;font-size:11px}#pip{width:170px}}
</style>
</head>
<body>
<div id="stage"><canvas id="main"></canvas></div>
<div id="progress"></div>
<div id="zoombox"></div>
<div id="hud" aria-live="polite">
<div class="mag"><span id="hMag">×1</span><span class="modeTag" id="hMode">MANDELBROT</span></div>
<div class="row"><span>center</span><b id="hCenter">—</b></div>
<div class="row"><span>width</span><b id="hWidth">—</b></div>
<div class="row"><span>iterations</span><b id="hIter">—</b></div>
<div class="row"><span>fps</span><b id="hFps">—</b></div>
<div class="row"><span>julia c</span><b id="hJulia">—</b></div>
</div>
<div id="panel">
<h3>Navigate</h3>
<div class="btnrow" role="toolbar" aria-label="navigation">
<button id="bBox" class="active" title="Box-zoom tool (left-drag draws rectangle)">⬚ Box</button>
<button id="bPan" title="Pan tool (left-drag pans)">✋ Pan</button>
<button id="bBack" title="Back (H)">← Back</button>
<button id="bReset" title="Reset (R)">⟲ Reset</button>
</div>
<h3>Set</h3>
<div class="btnrow">
<button id="bMandel" class="active">Mandelbrot</button>
<button id="bJulia">Julia (J)</button>
<button id="bPreview">Preview: off</button>
</div>
<label class="sl">Julia Re <output id="oJr">-0.80</output></label>
<input type="range" id="sJr" min="-2" max="2" step="0.001" value="-0.8">
<label class="sl">Julia Im <output id="oJi">0.156</output></label>
<input type="range" id="sJi" min="-2" max="2" step="0.001" value="0.156">
<h3>Palette</h3>
<div id="swatches" role="radiogroup" aria-label="palette"></div>
<label class="sl">Drift (recolors only) <output id="oOff">0.00</output></label>
<input type="range" id="sOff" min="0" max="1" step="0.002" value="0">
<label class="sl">Density <output id="oDen">1.00</output></label>
<input type="range" id="sDen" min="0.2" max="3" step="0.05" value="1">
<h3>Iterations</h3>
<div class="checkline"><input type="checkbox" id="cAuto" checked><label for="cAuto">Auto-scale with zoom</label></div>
<label class="sl">Limit <output id="oIter">150</output></label>
<input type="range" id="sIter" min="40" max="4000" step="10" value="150">
<div class="checkline"><input type="checkbox" id="cSmooth" checked><label for="cSmooth">Smooth coloring</label></div>
<h3>Bookmarks</h3>
<div style="display:flex;gap:6px"><input type="text" id="bmName" placeholder="name this view…" aria-label="bookmark name"><button id="bSave">Save</button></div>
<div id="bookmarks"></div>
<div id="help"><kbd>drag</kbd> box-zoom · <kbd>space+drag</kbd>/<kbd>right-drag</kbd> pan · <kbd>wheel</kbd> zoom · <kbd>←→↑↓</kbd> pan · <kbd>+/-</kbd> zoom · <kbd>J</kbd> julia · <kbd>H</kbd> back · <kbd>R</kbd> reset · <kbd>1–5</kbd> palette · <kbd>U</kbd> hide UI</div>
</div>
<div id="pip"><canvas id="pipCanvas" width="232" height="150"></canvas><div class="cap"><span>c = <b id="pipC">—</b></span><span style="cursor:pointer" id="pipOpen">open Julia ⧉</span></div></div>
<div id="pipHint">hover Mandelbrot to preview Julia · click to open · <b>Esc</b> exits preview</div>
<div id="toast"></div>
<button id="toggleUI" title="hide/show UI (U)">◐ UI</button>
<script>
"use strict";
/* =====================================================================
SECTION 1 — TUNABLE CONSTANTS
===================================================================== */
const CONFIG = {
baseWidth: 3.4, // complex width of reset view (mag ×1)
resetCX: -0.5, resetCY: 0,
baseIter: 150, // iterations at mag ×1
iterPerDecade: 110, // +this many iters per 10× magnification
maxIterCap: 5000, // hard cap for auto scaling
bailout2: 4, // escape radius² (|z|² > 4)
zoomSpeed: 0.0016, // wheel: w *= exp(dy*zoomSpeed)
boxMinPx: 6, // smaller drag = click, not zoom
maxMag: 1e13, // precision cap — zoom blocked past this
coarseStride: 4, // progressive coarse pass stride
rowsPerSlice: 48, // full-res rows per frame slice (perf)
sliceBudgetMs: 12, // max ms per frame slice
lutSize: 1024, // palette LUT entries
cycleLen: 64, // palette cycles every N iterations @density 1
dprMax: 1.5, // cap devicePixelRatio for perf
previewThrottleMs: 70,
idleHideMs: 3500
};
const PALETTES = [
{ name:"Fire", stops:[[0,"#0b0008"],[0.22,"#7a0d0d"],[0.45,"#e33d00"],[0.65,"#ffb300"],[0.82,"#fff3c4"],[1,"#0b0008"]] },
{ name:"Ocean", stops:[[0,"#010a1a"],[0.25,"#03396c"],[0.5,"#0096c7"],[0.72,"#90e0ef"],[0.88,"#f0fdff"],[1,"#010a1a"]] },
{ name:"Mono", stops:[[0,"#000000"],[0.3,"#2b2b2e"],[0.55,"#8e8e93"],[0.8,"#f2f2f2"],[1,"#000000"]] },
{ name:"Psy", stops:[[0,"#ff004c"],[0.2,"#ff8a00"],[0.4,"#ffee00"],[0.55,"#00e676"],[0.7,"#00b0ff"],[0.85,"#d500f9"],[1,"#ff004c"]] },
{ name:"Nebula",stops:[[0,"#060214"],[0.28,"#3b0764"],[0.5,"#7c3aed"],[0.68,"#ec4899"],[0.85,"#67e8f9"],[1,"#060214"]] }
];
/* =====================================================================
SECTION 2 — STATE
===================================================================== */
const main = document.getElementById('main');
const ctx = main.getContext('2d');
const pip = document.getElementById('pipCanvas');
const pctx = pip.getContext('2d');
const $ = id => document.getElementById(id);
const S = {
cx: CONFIG.resetCX, cy: CONFIG.resetCY, w: CONFIG.baseWidth,
mode: 'mandelbrot', jre: -0.8, jim: 0.156,
pal: 0, offset: 0, density: 1, smooth: true,
autoIter: true, manualIter: 150, maxIter: 150,
tool: 'box', preview: false,
W: 0, H: 0
};
let history = [];
let LUTS = [];
let img = null, smoothBuf = null;
let renderToken = 0, fineY = 0, rendering = false;
let fpsEMA = 60, lastFrame = performance.now(), frames = 0;
let coarseOn = true;
/* =====================================================================
SECTION 3 — PALETTES / LUT
===================================================================== */
function hexRGB(h){ h=h.replace('#',''); return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)]; }
function buildLUTS(){
LUTS = PALETTES.map(p=>{
const stops = p.stops.map(([t,c])=>[t,hexRGB(c)]);
const lut = new Uint8Array(CONFIG.lutSize*3);
for(let i=0;i<CONFIG.lutSize;i++){
const t=i/CONFIG.lutSize;
let k=0; while(k<stops.length-2 && t>stops[k+1][0]) k++;
const [t0,c0]=stops[k],[t1,c1]=stops[k+1];
const f=Math.min(1,Math.max(0,(t-t0)/Math.max(1e-9,t1-t0)));
lut[i*3]=c0[0]+(c1[0]-c0[0])*f; lut[i*3+1]=c0[1]+(c1[1]-c0[1])*f; lut[i*3+2]=c0[2]+(c1[2]-c0[2])*f;
}
return lut;
});
}
function buildSwatches(){
const box=$('swatches'); box.innerHTML='';
PALETTES.forEach((p,i)=>{
const b=document.createElement('button');
b.className='swatch'+(i===S.pal?' active':''); b.title=p.name+` (${i+1})`;
b.setAttribute('role','radio'); b.setAttribute('aria-label',p.name);
const c0=p.stops[1][1],c1=p.stops[2][1],c2=p.stops[3][1];
b.style.background=`linear-gradient(90deg,${c0},${c1},${c2})`;
b.onclick=()=>setPalette(i);
box.appendChild(b);
});
}
function setPalette(i){ S.pal=i; [...$('swatches').children].forEach((el,k)=>el.classList.toggle('active',k===i)); recolorize(); scheduleHash(); }
/* =====================================================================
SECTION 4 — CORE MATH (allocation-free escape loops)
===================================================================== */
function mandelIterInt(cre,cim,maxIter){
let zr=0,zi=0,i=0;
const R2=CONFIG.bailout2;
while(i<maxIter){ const zr2=zr*zr,zi2=zi*zi; if(zr2+zi2>R2) break; const nzr=zr2-zi2+cre; zi=2*zr*zi+cim; zr=nzr; i++; }
return i;
}
function juliaIterInt(zr,zi,cre,cim,maxIter){
let i=0; const R2=CONFIG.bailout2;
while(i<maxIter){ const zr2=zr*zr,zi2=zi*zi; if(zr2+zi2>R2) break; const nzr=zr2-zi2+cre; zi=2*zr*zi+cim; zr=nzr; i++; }
return i;
}
// smooth escape: returns {iter, smooth}; smooth=-1 => interior
function mandelSmooth(cre,cim,maxIter){
let zr=0,zi=0,i=0; const R2=CONFIG.bailout2;
while(i<maxIter){ const zr2=zr*zr,zi2=zi*zi; if(zr2+zi2>R2) break; const nzr=zr2-zi2+cre; zi=2*zr*zi+cim; zr=nzr; i++; }
if(i>=maxIter) return -1;
const r2=zr*zr+zi*zi;
const log_zn=0.5*Math.log(r2);
const nu=Math.log(log_zn/Math.LN2)/Math.LN2;
return i+1-nu;
}
function juliaSmooth(zr,zi,cre,cim,maxIter){
let i=0; const R2=CONFIG.bailout2;
while(i<maxIter){ const zr2=zr*zr,zi2=zi*zi; if(zr2+zi2>R2) break; const nzr=zr2-zi2+cre; zi=2*zr*zi+cim; zr=nzr; i++; }
if(i>=maxIter) return -1;
const r2=zr*zr+zi*zi;
const log_zn=0.5*Math.log(r2);
const nu=Math.log(log_zn/Math.LN2)/Math.LN2;
return i+1-nu;
}
/* =====================================================================
SECTION 5 — VIEW MAPPING + ITER SCALING + MAG
===================================================================== */
const mag = ()=> CONFIG.baseWidth / S.w;
function autoIters(){ const m=Math.max(1,mag()); const v=Math.round(CONFIG.baseIter+CONFIG.iterPerDecade*Math.log10(m)); return Math.min(CONFIG.maxIterCap,Math.max(40,v)); }
function refreshIters(){ S.maxIter = S.autoIter ? autoIters() : S.manualIter; $('oIter').textContent=S.maxIter; if(+$('sIter').value!==S.manualIter && !S.autoIter){} $('hIter').textContent=S.maxIter+(S.autoIter?' (auto)':''); }
function pxToComplex(x,y){ const upp=S.w/S.W; return [S.cx+(x-S.W/2)*upp, S.cy-(y-S.H/2)*upp]; }
function complexToPx(re,im){ const upp=S.w/S.W; return [(re-S.cx)/upp+S.W/2, (S.cy-im)/upp+S.H/2]; }
/* =====================================================================
SECTION 6 — RENDERER (progressive, cancellable, recolorize-only)
===================================================================== */
function ensureBuffers(){
const dpr=Math.min(window.devicePixelRatio||1,CONFIG.dprMax);
const W=Math.max(2,Math.floor(window.innerWidth*dpr)), H=Math.max(2,Math.floor(window.innerHeight*dpr));
if(W!==S.W||H!==S.H||!img){
S.W=W; S.H=H; main.width=W; main.height=H;
img=ctx.createImageData(W,H); smoothBuf=new Float32Array(W*H);
}
}
function colorizePixel(smooth,idx,out,lut){
if(smooth<0){ out[idx]=0;out[idx+1]=0;out[idx+2]=0;out[idx+3]=255; return; }
let p;
if(S.smooth){ p=(smooth*S.density*(CONFIG.lutSize/CONFIG.cycleLen)+S.offset*CONFIG.lutSize)%CONFIG.lutSize; if(p<0)p+=CONFIG.lutSize; }
else { const it=Math.floor(smooth); p=((it%CONFIG.cycleLen)/CONFIG.cycleLen*CONFIG.lutSize+S.offset*CONFIG.lutSize)%CONFIG.lutSize; }
const li=Math.floor(p)%CONFIG.lutSize, lj=(li+1)%CONFIG.lutSize, f=p-Math.floor(p);
const o=idx;
out[o] =lut[li*3]+(lut[lj*3]-lut[li*3])*f;
out[o+1]=lut[li*3+1]+(lut[lj*3+1]-lut[li*3+1])*f;
out[o+2]=lut[li*3+2]+(lut[lj*3+2]-lut[li*3+2])*f;
out[o+3]=255;
}
function computePoint(re,im){
if(S.mode==='mandelbrot'){
if(S.smooth) return mandelSmooth(re,im,S.maxIter);
const k=mandelIterInt(re,im,S.maxIter); return k>=S.maxIter?-1:k;
}
if(S.smooth) return juliaSmooth(re,im,S.jre,S.jim,S.maxIter);
const k=juliaIterInt(re,im,S.jre,S.jim,S.maxIter); return k>=S.maxIter?-1:k;
}
function coarsePass(){
const st=CONFIG.coarseStride, lut=LUTS[S.pal], d=img.data, W=S.W,H=S.H;
const upp=S.w/W;
for(let y=0;y<H;y+=st){
const im=S.cy-(y+st/2-H/2)*upp;
for(let x=0;x<W;x+=st){
const re=S.cx+(x+st/2-W/2)*upp;
const s=computePoint(re,im);
for(let by=0;by<st&&y+by<H;by++)for(let bx=0;bx<st&&x+bx<W;bx++){
const pi=(y+by)*W+(x+bx); smoothBuf[pi]=s; colorizePixel(s,pi*4,d,lut);
}
}
}
ctx.putImageData(img,0,0);
}
function renderRows(y0,y1){
const lut=LUTS[S.pal], d=img.data, W=S.W, H=S.H;
const upp=S.w/W;
for(let y=y0;y<y1;y++){
const im=S.cy-(y-H/2)*upp;
for(let x=0;x<W;x++){
const re=S.cx+(x-W/2)*upp;
const s=computePoint(re,im);
const pi=y*W+x; smoothBuf[pi]=s; colorizePixel(s,pi*4,d,lut);
}
}
}
function requestRender(){
ensureBuffers(); refreshIters();
renderToken++; const my=renderToken; rendering=true; fineY=0;
$('progress').style.width='4%';
if(coarseOn){ try{ coarsePass(); }catch(e){} if(my!==renderToken) return; $('progress').style.width='22%'; }
updateHUD();
requestAnimationFrame(()=>fineStep(my));
}
function fineStep(my){
if(my!==renderToken) return;
const t0=performance.now();
let yEnd=fineY;
while(fineY<S.H && (performance.now()-t0)<CONFIG.sliceBudgetMs){
const y1=Math.min(S.H,fineY+CONFIG.rowsPerSlice);
renderRows(fineY,y1); fineY=y1;
}
if(my!==renderToken) return; // cancelled mid-slice
ctx.putImageData(img,0,0);
$('progress').style.width=(22+78*(fineY/S.H))+'%';
if(fineY<S.H){ requestAnimationFrame(()=>fineStep(my)); }
else { rendering=false; $('progress').style.width='100%'; setTimeout(()=>{ if(!rendering) $('progress').style.width='0%'; },250); updateHUD(); }
}
function recolorize(){
if(!img||!smoothBuf) return;
const lut=LUTS[S.pal], d=img.data, n=S.W*S.H;
for(let pi=0;pi<n;pi++) colorizePixel(smoothBuf[pi],pi*4,d,lut);
ctx.putImageData(img,0,0);
}
/* =====================================================================
SECTION 7 — NAVIGATION (box zoom exact, wheel, pan, keys, history)
===================================================================== */
function pushHist(){ history.push({cx:S.cx,cy:S.cy,w:S.w,mode:S.mode,jre:S.jre,jim:S.jim,pal:S.pal}); if(history.length>60)history.shift(); }
function goBack(){ const p=history.pop(); if(!p){toast('No history');return;} Object.assign(S,{cx:p.cx,cy:p.cy,w:p.w,mode:p.mode,jre:p.jre,jim:p.jim,pal:p.pal}); syncModeUI(); requestRender(); scheduleHash(); }
function resetView(){ pushHist(); S.cx=CONFIG.resetCX;S.cy=CONFIG.resetCY;S.w=CONFIG.baseWidth;S.mode='mandelbrot'; syncModeUI(); requestRender(); scheduleHash(); }
function clampDepth(w){
const wMin=CONFIG.baseWidth/CONFIG.maxMag;
if(w<wMin){ toast(`Precision limit reached — capped at ×${CONFIG.maxMag.toExponential(0)} (double precision degrades beyond this)`); return wMin; }
return w;
}
// Exact box zoom: pixel rect -> complex center + width (aspect-contained)
function zoomToBox(x0,y0,x1,y1){
const xa=Math.min(x0,x1),xb=Math.max(x0,x1),ya=Math.min(y0,y1),yb=Math.max(y0,y1);
if(xb-xa<CONFIG.boxMinPx||yb-ya<CONFIG.boxMinPx) return false;
const [reA,imA]=pxToComplex(xa,ya),[reB,imB]=pxToComplex(xb,yb);
const selW=Math.abs(reB-reA), selH=Math.abs(imB-imA);
const aspect=S.W/S.H;
const newW=clampDepth(Math.max(selW,selH*aspect));
pushHist();
S.cx=(reA+reB)/2; S.cy=(imA+imB)/2; S.w=newW;
requestRender(); scheduleHash(); return true;
}
function wheelZoom(cx_px,cy_px,factor){
const [re,im]=pxToComplex(cx_px,cy_px);
let newW=clampDepth(S.w*factor);
const blocked=newW===S.w&&factor<1;
if(S.w*factor!==newW&&factor<1) toast(`Precision limit — capped at ×${CONFIG.maxMag.toExponential(0)}`);
pushHistThrottled();
S.w=newW;
const upp=S.w/S.W;
S.cx=re-(cx_px-S.W/2)*upp; S.cy=im+(cy_px-S.H/2)*upp;
requestRender(); scheduleHash();
}
let lastPush=0;
function pushHistThrottled(){ const n=performance.now(); if(n-lastPush>400){pushHist();lastPush=n;} }
// pointer interactions
let drag=null, panning=null, spaceDown=false;
const zbox=$('zoombox');
main.addEventListener('pointerdown',e=>{
main.setPointerCapture(e.pointerId);
const r=main.getBoundingClientRect();
const x=(e.clientX-r.left)*(S.W/r.width), y=(e.clientY-r.top)*(S.H/r.height);
if(e.button===2||e.button===1||spaceDown||S.tool==='pan'){ panning={x,y,cx:S.cx,cy:S.cy,moved:false}; }
else { drag={x0:x,y0:y,x1:x,y1:y}; }
});
main.addEventListener('pointermove',e=>{
pokeUI();
const r=main.getBoundingClientRect();
const x=(e.clientX-r.left)*(S.W/r.width), y=(e.clientY-r.top)*(S.H/r.height);
if(panning){
const upp=S.w/S.W, dx=x-panning.x, dy=y-panning.y;
if(Math.abs(dx)+Math.abs(dy)>1) panning.moved=true;
S.cx=panning.cx-dx*upp; S.cy=panning.cy+dy*upp;
requestRender(); scheduleHash();
} else if(drag){
drag.x1=x; drag.y1=y;
const rr=main.getBoundingClientRect();
const sx=v=>v/S.W*rr.width, sy=v=>v/S.H*rr.height;
const l=Math.min(drag.x0,drag.x1),t=Math.min(drag.y0,drag.y1),ww=Math.abs(drag.x1-drag.x0),hh=Math.abs(drag.y1-drag.y0);
zbox.style.display='block';
zbox.style.left=(rr.left+sx(l))+'px'; zbox.style.top=(rr.top+sy(t))+'px';
zbox.style.width=sx(ww)+'px'; zbox.style.height=sy(hh)+'px';
}
if(S.preview&&S.mode==='mandelbrot') hoverPreview(e);
});
window.addEventListener('pointerup',e=>{
if(panning){ if(panning.moved) pushHist(); panning=null; scheduleHash(); }
if(drag){
const d=drag; drag=null; zbox.style.display='none';
const wpx=Math.abs(d.x1-d.x0),hpx=Math.abs(d.y1-d.y0);
if(wpx<CONFIG.boxMinPx&&hpx<CONFIG.boxMinPx){
// click: open Julia if preview mode
if(S.preview&&S.mode==='mandelbrot'){ const [re,im]=pxToComplex(d.x0,d.y0); openJulia(re,im); }
} else zoomToBox(d.x0,d.y0,d.x1,d.y1);
}
});
main.addEventListener('contextmenu',e=>e.preventDefault());
main.addEventListener('wheel',e=>{
e.preventDefault(); pokeUI();
const r=main.getBoundingClientRect();
const x=(e.clientX-r.left)*(S.W/r.width), y=(e.clientY-r.top)*(S.H/r.height);
wheelZoom(x,y,Math.exp(e.deltaY*CONFIG.zoomSpeed));
},{passive:false});
window.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'&&e.target.type==='text') return;
const step=S.w*0.06;
if(e.code==='Space') spaceDown=true;
if(e.key==='ArrowLeft'){pushHist();S.cx-=step;requestRender();scheduleHash();e.preventDefault();}
else if(e.key==='ArrowRight'){pushHist();S.cx+=step;requestRender();scheduleHash();e.preventDefault();}
else if(e.key==='ArrowUp'){pushHist();S.cy+=step;requestRender();scheduleHash();e.preventDefault();}
else if(e.key==='ArrowDown'){pushHist();S.cy-=step;requestRender();scheduleHash();e.preventDefault();}
else if(e.key==='+'||e.key==='='){pushHist();S.w=clampDepth(S.w/1.25);requestRender();scheduleHash();}
else if(e.key==='-'||e.key==='_'){pushHist();S.w=clampDepth(S.w*1.25);requestRender();scheduleHash();}
else if(e.key==='h'||e.key==='H') goBack();
else if(e.key==='r'||e.key==='R') resetView();
else if(e.key==='j'||e.key==='J'){ S.mode==='mandelbrot'?openJulia(S.cx,S.cy):closeJulia(); }
else if(e.key==='u'||e.key==='U') toggleUI();
else if(e.key==='Escape'){ if(S.preview&&S.mode==='mandelbrot') setPreview(false); }
else if(e.key>='1'&&e.key<='5') setPalette(+e.key-1);
});
window.addEventListener('keyup',e=>{ if(e.code==='Space') spaceDown=false; });
// fix ArrowUp double logic: use consistent pan
window.addEventListener('keydown',e=>{
if(e.key==='ArrowUp'&&e.target===document.body){ /* handled above */ }
});
/* =====================================================================
SECTION 8 — JULIA MODE + HOVER PREVIEW
===================================================================== */
function openJulia(re,im){ pushHist(); S.jre=re;S.jim=im;S.mode='julia'; $('sJr').value=re;$('sJi').value=im;$('oJr').textContent=(+re).toFixed(3);$('oJi').textContent=(+im).toFixed(3); syncModeUI(); requestRender(); scheduleHash(); }
function closeJulia(){ pushHist(); S.mode='mandelbrot'; syncModeUI(); requestRender(); scheduleHash(); }
function syncModeUI(){
$('bMandel').classList.toggle('active',S.mode==='mandelbrot');
$('bJulia').classList.toggle('active',S.mode==='julia');
$('hMode').textContent=S.mode.toUpperCase();
$('hJulia').textContent=`${(+S.jre).toFixed(4)}, ${(+S.jim).toFixed(4)}`;
$('pipHint').classList.toggle('show',S.preview&&S.mode==='mandelbrot');
}
function setPreview(on){
S.preview=on; $('bPreview').textContent='Preview: '+(on?'on':'off'); $('bPreview').classList.toggle('active',on);
$('pip').classList.toggle('show',on&&S.mode==='mandelbrot');
$('pipHint').classList.toggle('show',on&&S.mode==='mandelbrot');
main.style.cursor=on&&S.mode==='mandelbrot'?'crosshair':'';
}
let lastPrev=0,lastPrevC=null;
function hoverPreview(e){
const now=performance.now(); if(now-lastPrev<CONFIG.previewThrottleMs) return; lastPrev=now;
const r=main.getBoundingClientRect();
const x=(e.clientX-r.left)*(S.W/r.width), y=(e.clientY-r.top)*(S.H/r.height);
const [re,im]=pxToComplex(x,y); lastPrevC=[re,im];
renderPip(re,im);
$('pipC').textContent=`${re.toFixed(4)}, ${im.toFixed(4)}`;
}
function renderPip(cre,cim){
const W=pip.width,H=pip.height, id=pctx.createImageData(W,H), d=id.data, lut=LUTS[S.pal];
const jr=cre,ji=cim;
// frame Julia set fully in view: width ~3.4 (or match main aspect)
const wJ=3.2, upp=wJ/W, cxJ=0, cyJ=0, mi=220;
for(let y=0;y<H;y++){ const im0=cyJ-(y-H/2)*upp;
for(let x=0;x<W;x++){ const re0=cxJ+(x-W/2)*upp;
const s=S.smooth?juliaSmooth(re0,im0,jr,ji,mi):(juliaIterInt(re0,im0,jr,ji,mi)>=mi?-1:juliaIterInt(re0,im0,jr,ji,mi));
let p;
if(s<0){d[(y*W+x)*4]=0;d[(y*W+x)*4+1]=0;d[(y*W+x)*4+2]=0;d[(y*W+x)*4+3]=255;continue;}
p=(s*S.density*(CONFIG.lutSize/CONFIG.cycleLen)+S.offset*CONFIG.lutSize)%CONFIG.lutSize; if(p<0)p+=CONFIG.lutSize;
const li=Math.floor(p)%CONFIG.lutSize,lj=(li+1)%CONFIG.lutSize,f=p-Math.floor(p);
d[(y*W+x)*4]=lut[li*3]+(lut[lj*3]-lut[li*3])*f;
d[(y*W+x)*4+1]=lut[li*3+1]+(lut[lj*3+1]-lut[li*3+1])*f;
d[(y*W+x)*4+2]=lut[li*3+2]+(lut[lj*3+2]-lut[li*3+2])*f;
d[(y*W+x)*4+3]=255;
}
}
pctx.putImageData(id,0,0);
// reticle
pctx.strokeStyle='rgba(255,255,255,.8)';pctx.lineWidth=1;
pctx.beginPath();pctx.moveTo(W/2-8,H/2);pctx.lineTo(W/2+8,H/2);pctx.moveTo(W/2,H/2-8);pctx.lineTo(W/2,H/2+8);pctx.stroke();
}
/* =====================================================================
SECTION 9 — BOOKMARKS + URL HASH
===================================================================== */
const LSKEY='fractalBookmarks.v1';
function viewState(name){ return {name:name||'',cx:S.cx,cy:S.cy,w:S.w,mode:S.mode,jre:S.jre,jim:S.jim,pal:S.pal,offset:S.offset,density:S.density,autoIter:S.autoIter,manualIter:S.manualIter,smooth:S.smooth}; }
function applyState(v){ S.cx=v.cx;S.cy=v.cy;S.w=clampDepth(v.w);S.mode=v.mode||'mandelbrot';S.jre=v.jre??S.jre;S.jim=v.jim??S.jim;S.pal=v.pal??0;S.offset=v.offset??0;S.density=v.density??1;S.autoIter=v.autoIter??true;S.manualIter=v.manualIter??150;S.smooth=v.smooth??true;
$('sOff').value=S.offset;$('oOff').textContent=(+S.offset).toFixed(2);
$('sDen').value=S.density;$('oDen').textContent=(+S.density).toFixed(2);
$('sIter').value=S.manualIter;$('cAuto').checked=S.autoIter;$('cSmooth').checked=S.smooth;
$('sJr').value=S.jre;$('sJi').value=S.jim;$('oJr').textContent=(+S.jre).toFixed(3);$('oJi').textContent=(+S.jim).toFixed(3);
buildSwatchesActive(); syncModeUI(); requestRender(); }
function buildSwatchesActive(){ [...$('swatches').children].forEach((el,k)=>el.classList.toggle('active',k===S.pal)); }
function loadBMs(){ try{return JSON.parse(localStorage.getItem(LSKEY)||'{}');}catch(e){return{};} }
function saveBMs(o){ localStorage.setItem(LSKEY,JSON.stringify(o)); }
function renderBMs(){
const all=loadBMs(),box=$('bookmarks');box.innerHTML='';
Object.keys(all).forEach(n=>{
const d=document.createElement('div');d.className='bm';
const s=document.createElement('span');s.textContent=n;s.title='restore';s.tabIndex=0;
s.onclick=()=>{pushHist();applyState(all[n]);scheduleHash();};
s.onkeydown=(e)=>{if(e.key==='Enter'){pushHist();applyState(all[n]);scheduleHash();}};
const del=document.createElement('button');del.textContent='✕';del.title='delete';del.onclick=()=>{const a=loadBMs();delete a[n];saveBMs(a);renderBMs();};
d.appendChild(s);d.appendChild(del);box.appendChild(d);
});
if(!Object.keys(all).length){box.innerHTML='<div style="color:#5b6a82;font-size:11px">no bookmarks yet</div>';}
}
let hashT=null;
function scheduleHash(){ clearTimeout(hashT); hashT=setTimeout(writeHash,400); }
function writeHash(){
const p=[S.cx.toPrecision(12),S.cy.toPrecision(12),S.w.toPrecision(12),S.mode,S.jre.toFixed(5),S.jim.toFixed(5),S.pal,S.offset.toFixed(3),S.manualIter,S.autoIter?1:0].join(',');
try{history.replaceState(null,'','#'+encodeURIComponent(p));}catch(e){}
}
function readHash(){
if(!location.hash||location.hash.length<3) return null;
try{
const p=decodeURIComponent(location.hash.slice(1)).split(',');
if(p.length<4) return null;
return {cx:+p[0],cy:+p[1],w:+p[2],mode:p[3]==='julia'?'julia':'mandelbrot',jre:+p[4],jim:+p[5],pal:+p[6]|0,offset:+p[7]||0,manualIter:+p[8]||150,autoIter:p[9]==='1'};
}catch(e){return null;}
}
/* =====================================================================
SECTION 10 — HUD / FPS / TOAST / UI VISIBILITY
===================================================================== */
function fmtMag(m){ if(m<1000) return '×'+(Math.round(m*10)/10); return '×'+m.toExponential(1); }
function updateHUD(){
$('hMag').textContent=fmtMag(mag());
$('hCenter').textContent=`${S.cx.toPrecision(7)}, ${S.cy.toPrecision(7)}`;
$('hWidth').textContent=`${S.w.toExponential(3)}`;
$('hIter').textContent=S.maxIter+(S.autoIter?' (auto)':'');
$('hJulia').textContent=`${(+S.jre).toFixed(4)}, ${(+S.jim).toFixed(4)}`;
$('hMode').textContent=S.mode.toUpperCase();
}
let toastT=null;
function toast(msg){ const t=$('toast');t.textContent=msg;t.classList.add('show');clearTimeout(toastT);toastT=setTimeout(()=>t.classList.remove('show'),3200); }
let idleT=null,uiHidden=false;
function pokeUI(){
if(uiHidden){$('hud').classList.remove('hidden');$('panel').classList.remove('hidden');uiHidden=false;}
clearTimeout(idleT); idleT=setTimeout(()=>{$('hud').classList.add('hidden');$('panel').classList.add('hidden');uiHidden=true;},CONFIG.idleHideMs);
}
function toggleUI(){ uiHidden=!uiHidden;$('hud').classList.toggle('hidden',uiHidden);$('panel').classList.toggle('hidden',uiHidden); if(!uiHidden)pokeUI(); }
function fpsLoop(){
const n=performance.now(),dt=n-lastFrame;lastFrame=n;
if(dt>0)fpsEMA=fpsEMA*0.92+(1000/dt)*0.08;
if(++frames%20===0)$('hFps').textContent=Math.round(fpsEMA);
requestAnimationFrame(fpsLoop);
}
/* =====================================================================
SECTION 11 — WIRING + DEBUG INTERFACE
===================================================================== */
function wire(){
$('bBox').onclick=()=>{S.tool='box';$('bBox').classList.add('active');$('bPan').classList.remove('active');main.classList.remove('panMode');};
$('bPan').onclick=()=>{S.tool='pan';$('bPan').classList.add('active');$('bBox').classList.remove('active');main.classList.add('panMode');};
$('bBack').onclick=goBack; $('bReset').onclick=resetView;
$('bMandel').onclick=closeJulia; $('bJulia').onclick=()=>openJulia(S.mode==='julia'?S.jre:S.cx,S.mode==='julia'?S.jim:S.cy);
$('bPreview').onclick=()=>setPreview(!S.preview);
$('pipOpen').onclick=()=>{ if(lastPrevC) openJulia(lastPrevC[0],lastPrevC[1]); };
$('toggleUI').onclick=toggleUI;
$('sOff').oninput=e=>{S.offset=+e.target.value;$('oOff').textContent=S.offset.toFixed(2);recolorize();if(S.preview)renderPipThrottled();scheduleHash();};
$('sDen').oninput=e=>{S.density=+e.target.value;$('oDen').textContent=S.density.toFixed(2);recolorize();scheduleHash();};
$('sIter').oninput=e=>{S.manualIter=+e.target.value;$('oIter').textContent=S.manualIter;if(!S.autoIter){refreshIters();requestRender();}scheduleHash();};
$('cAuto').onchange=e=>{S.autoIter=e.target.checked;refreshIters();requestRender();scheduleHash();};
$('cSmooth').onchange=e=>{S.smooth=e.target.checked;requestRender();scheduleHash();};
$('sJr').oninput=e=>{S.jre=+e.target.value;$('oJr').textContent=S.jre.toFixed(3);if(S.mode==='julia')requestRender();scheduleHash();};
$('sJi').oninput=e=>{S.jim=+e.target.value;$('oJi').textContent=S.jim.toFixed(3);if(S.mode==='julia')requestRender();scheduleHash();};
$('bSave').onclick=()=>{
const n=$('bmName').value.trim()||('view '+(Object.keys(loadBMs()).length+1));
const a=loadBMs();a[n]=viewState(n);saveBMs(a);renderBMs();$('bmName').value='';toast(`Bookmarked “${n}”`);
};
let rzT=null; window.addEventListener('resize',()=>{clearTimeout(rzT);rzT=setTimeout(()=>{ensureBuffers();requestRender();},150);});
document.addEventListener('mousemove',pokeUI);
}
let pipT=null;
function renderPipThrottled(){ if(!lastPrevC)return; clearTimeout(pipT); pipT=setTimeout(()=>renderPip(lastPrevC[0],lastPrevC[1]),120); }
window.fractal = {
iterationsAt(re,im,max){ const m=max||S.maxIter; if(S.mode==='julia') return juliaIterInt(re,im,S.jre,S.jim,m); return mandelIterInt(re,im,m); },
smoothAt(re,im,max){ const m=max||S.maxIter; if(S.mode==='julia') return juliaSmooth(re,im,S.jre,S.jim,m); return mandelSmooth(re,im,m); },
setView(v){ if(v.re!==undefined)S.cx=+v.re; if(v.im!==undefined)S.cy=+v.im; if(v.cx!==undefined)S.cx=+v.cx; if(v.cy!==undefined)S.cy=+v.cy;
if(v.scale!==undefined){ const s=+v.scale; S.w = s>10 ? CONFIG.baseWidth/s : s; } // scale>10 => magnification
if(v.w!==undefined)S.w=+v.w; if(v.mag!==undefined)S.w=CONFIG.baseWidth/+v.mag;
if(v.jre!==undefined)S.jre=+v.jre; if(v.jim!==undefined)S.jim=+v.jim; if(v.mode)S.mode=v.mode; if(v.pal!==undefined)S.pal=v.pal;
S.w=clampDepth(S.w); syncModeUI(); requestRender(); scheduleHash(); return this.getView(); },
getView(){ return {re:S.cx,im:S.cy,cx:S.cx,cy:S.cy,scale:S.w,w:S.w,mag:mag(),maxIter:S.maxIter,mode:S.mode,jre:S.jre,jim:S.jim,pal:S.pal,offset:S.offset}; },
zoomToBox(x0,y0,x1,y1){ return zoomToBox(x0,y0,x1,y1); },
reset:()=>resetView(), back:()=>goBack(),
renderStats:()=>({token:renderToken,rendering,W:S.W,H:S.H,maxIter:S.maxIter,mag:mag()}),
config:CONFIG
};
/* =====================================================================
SECTION 12 — INIT
===================================================================== */
buildLUTS(); buildSwatches(); wire(); renderBMs(); syncModeUI(); setPreview(false);
(function init(){
const h=readHash(); if(h) applyStateDirect(h); else { ensureBuffers(); refreshIters(); }
function applyStateDirect(v){ S.cx=v.cx;S.cy=v.cy;S.w=clampDepth(v.w);S.mode=v.mode;S.jre=v.jre||S.jre;S.jim=v.jim||S.jim;S.pal=v.pal||0;S.offset=v.offset||0;S.manualIter=v.manualIter||150;S.autoIter=!!v.autoIter;
$('sOff').value=S.offset;$('oOff').textContent=(+S.offset).toFixed(2);$('sIter').value=S.manualIter;$('cAuto').checked=S.autoIter;
buildSwatchesActive(); syncModeUI(); }
ensureBuffers(); refreshIters(); updateHUD(); requestRender(); pokeUI(); requestAnimationFrame(fpsLoop);
})();
</script>
</body>
</html>