Fractal Explorer
muse spark 1.2 · basic

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 37,809 bytes · SHA-256 a56acf03f87b
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
:root{--bg:#06070a;--glass:rgba(18,20,28,0.72);--border:rgba(255,255,255,0.09);--text:#eef2ff;--muted:#9aa3b8;--acc:#7cf8d6;--acc2:#8b7cff}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;overflow:hidden}
#wrap{position:relative;width:100vw;height:100vh;overflow:hidden;background:radial-gradient(1200px 800px at 30% 20%,#1a2440 0%,#0a0e1a 45%,#05070c 100%)}
canvas#main{position:absolute;inset:0;width:100%;height:100%;display:block;cursor:crosshair}
canvas#preview{width:160px;height:160px;border-radius:14px;background:#000;display:block}
/* HUD */
.hud{position:absolute;backdrop-filter:blur(18px) saturate(140%);background:var(--glass);border:1px solid var(--border);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,0.45),inset 0 1px 0 rgba(255,255,255,0.06)}
.topbar{top:14px;left:14px;right:14px;display:flex;align-items:center;gap:12px;padding:10px 12px;flex-wrap:wrap}
.brand{font-weight:700;letter-spacing:.02em;display:flex;align-items:center;gap:10px}
.brand i{width:28px;height:28px;border-radius:9px;background:conic-gradient(from 0deg,#7cf8d6,#8b7cff,#ff6b9d,#ffbe4c,#7cf8d6);display:grid;place-items:center;color:#000;font-weight:900;font-size:14px}
.readouts{display:flex;gap:14px;flex-wrap:wrap;font:12px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;color:#cbd5e1}
.readouts b{color:#fff;font-weight:600}
.chip{padding:4px 8px;border-radius:999px;background:rgba(255,255,255,0.07);border:1px solid rgba(255,255,255,0.08)}
.leftpanel{left:14px;top:78px;bottom:14px;width:300px;padding:14px;display:flex;flex-direction:column;gap:14px;overflow:auto;scrollbar-width:thin}
.rightpreview{right:14px;top:78px;padding:10px;display:flex;flex-direction:column;gap:10px;align-items:center;min-width:184px}
.panel-title{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted);font-weight:700}
.row{display:flex;gap:8px;flex-wrap:wrap}
button,select,input{font:inherit}
.btn{appearance:none;border:1px solid var(--border);background:rgba(255,255,255,0.06);color:var(--text);padding:8px 11px;border-radius:10px;cursor:pointer;font-size:13px;transition:.15s;display:inline-flex;align-items:center;gap:6px}
.btn:hover{background:rgba(255,255,255,0.11);transform:translateY(-1px)}
.btn.primary{background:linear-gradient(135deg,#7cf8d6,#8b7cff);color:#0a0a0f;border-color:transparent;font-weight:700}
.btn:active{transform:translateY(0)}
.iconbtn{width:32px;height:32px;display:grid;place-items:center;padding:0;border-radius:9px}
.swatches{display:flex;gap:8px}
.swatch{width:36px;height:28px;border-radius:8px;border:2px solid transparent;cursor:pointer;position:relative;overflow:hidden;box-shadow:inset 0 1px 0 rgba(255,255,255,0.2)}
.swatch.active{border-color:#fff;box-shadow:0 0 0 2px rgba(124,248,214,0.6)}
.slider{width:100%;accent-color:#7cf8d6}
label{font-size:12px;color:var(--muted);display:flex;justify-content:space-between;align-items:center;gap:8px}
label span{color:var(--text);font-family:ui-monospace,monospace;font-size:12px}
.kbd{font:11px ui-monospace,monospace;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.12);border-bottom-width:2px;padding:2px 6px;border-radius:6px;color:#e2e8f0}
.divider{height:1px;background:var(--border);margin:2px 0}
.zoombox{position:absolute;border:1px solid #fff;box-shadow:0 0 0 1px #000,0 0 12px rgba(124,248,214,0.6);pointer-events:none;display:none;mix-blend-mode:difference}
.cross{position:absolute;width:20px;height:20px;pointer-events:none;display:none}
.cross:before,.cross:after{content:"";position:absolute;background:#fff;mix-blend-mode:difference}
.cross:before{left:9px;top:0;width:2px;height:20px}.cross:after{top:9px;left:0;width:20px;height:2px}
.hint{font-size:11px;color:#a7b0c6;line-height:1.5}
.bookmarks{display:flex;flex-direction:column;gap:6px;max-height:140px;overflow:auto}
.bm{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:7px 9px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.06);font-size:12px}
.bm small{color:var(--muted);font-family:ui-monospace,monospace}
#toast{position:absolute;bottom:18px;left:50%;transform:translateX(-50%);background:rgba(10,12,18,0.9);border:1px solid var(--border);padding:8px 14px;border-radius:999px;font-size:12px;display:none}
@media(max-width:900px){.leftpanel{width:260px} .rightpreview{display:none}}
@media(max-width:640px){.leftpanel{top:auto;height:44vh;right:14px;width:auto} .topbar{padding:8px}}
</style>
</head>
<body>
<div id="wrap">
<canvas id="main"></canvas>
<div id="zoombox" class="zoombox"></div>
<div id="cross" class="cross"></div>
<!-- Top HUD -->
<div class="hud topbar" id="topbar">
<div class="brand"><i>∞</i> FRACTAL EXPLORER <span style="font-weight:400;color:var(--muted);font-size:12px;letter-spacing:0">Mandelbrot · Julia</span></div>
<div style="flex:1"></div>
<div class="readouts">
<span class="chip">× <b id="mag">1.00</b></span>
<span class="chip">C <b id="centerLbl">-0.5000 +0.0000i</b></span>
<span class="chip">iter <b id="iterLbl">256</b></span>
<span class="chip">fps <b id="fpsLbl">—</b></span>
<span class="chip" id="precLbl" style="display:none;color:#ff8a8a;border-color:rgba(255,100,100,0.3)">precision limit</span>
</div>
<div class="row">
<button class="btn iconbtn" id="backBtn" title="Back (history)">↩</button>
<button class="btn" id="resetBtn">Reset</button>
<button class="btn" id="juliaToggle">Julia: OFF</button>
<button class="btn" id="shareBtn">Share link</button>
</div>
</div>
<!-- Left Controls -->
<div class="hud leftpanel" id="leftpanel">
<div>
<div class="panel-title">Palette</div>
<div class="swatches" id="swatches"></div>
<div class="row" style="margin-top:8px">
<label style="flex:1">Cycle <input id="cycle" type="range" min="0" max="100" value="0" class="slider"></label>
<span class="kbd" id="cycleVal">0</span>
</div>
<label><span><input id="smoothChk" type="checkbox" checked> Smooth coloring</span></label>
</div>
<div class="divider"></div>
<div>
<div class="panel-title">Iterations & Depth</div>
<label>Max iter <span id="iterVal">256</span></label>
<input id="iterSlider" type="range" min="64" max="2048" step="32" value="256" class="slider">
<label><span><input id="autoIterChk" type="checkbox" checked> Auto-scale with zoom</span> <span id="autoIterVal" style="color:var(--muted)">on</span></label>
<div class="hint">Deeper zooms auto-increase iterations. Uncheck to lock slider. Limit ~5000.</div>
</div>
<div class="divider"></div>
<div>
<div class="panel-title">Navigate</div>
<div class="hint">
<span class="kbd">Drag</span> pan · <span class="kbd">Shift+Drag</span> rectangle zoom · <span class="kbd">Wheel</span> zoom to cursor<br>
<span class="kbd">Arrows</span> pan · <span class="kbd">+/-</span> zoom · <span class="kbd">J</span> toggle Julia · <span class="kbd">R</span> reset · <span class="kbd">B</span> back
</div>
<div class="row" style="margin-top:8px">
<button class="btn" id="zoominBtn">Zoom +</button>
<button class="btn" id="zoomoutBtn">Zoom −</button>
<button class="btn" id="locateSeahorse">Seahorse Valley</button>
</div>
</div>
<div class="divider"></div>
<div>
<div class="panel-title">Bookmarks</div>
<div class="row">
<input id="bmName" placeholder="Name this view" style="flex:1;background:rgba(255,255,255,0.06);border:1px solid var(--border);color:var(--text);padding:7px 9px;border-radius:9px">
<button class="btn primary" id="saveBm">Save</button>
</div>
<div class="bookmarks" id="bmList"></div>
</div>
<div class="hint" style="margin-top:auto">Double-precision limit ~1e13×. Zoom is clamped before precision turns to noise.</div>
</div>
<!-- Julia Preview -->
<div class="hud rightpreview" id="juliaPanel">
<div class="panel-title">Julia Preview <span style="text-transform:none;letter-spacing:0;color:#7cf8d6;font-size:10px" id="juliaCLabel">c = —</span></div>
<canvas id="preview" width="160" height="160"></canvas>
<div class="hint" style="text-align:center">Hover Mandelbrot to preview.<br>Click or press <span class="kbd">J</span> to open Julia.</div>
<div class="row"><button class="btn" id="swapJulia">Open Julia →</button></div>
</div>
<div id="toast"></div>
</div>
<script>
/* ============================================================
TUNABLE CONSTANTS — edit to taste
============================================================ */
const CONFIG = {
baseWidth: 3.5, // complex plane width at 1×
initCenterRe: -0.5,
initCenterIm: 0,
initScale: 3.5, // == baseWidth
maxMagnification: 1e13, // precision cap
minScale: 3.5/1e13,
baseIterations: 256,
iterScaleFactor: 55, // added per log2(mag)
maxIterationsHard: 5000,
zoomSpeed: 0.0018, // wheel
keyZoomFactor: 1.22,
panStep: 0.12, // fraction of view width per arrow
progressiveSteps: [4,2,1], // coarse → fine
previewSize: 160,
fpsSample: 600,
};
/* Palettes — 4 curated */
const PALETTES = {
fire: [ [0,0,0],[0.08,0.03,0.10],[0.45,0.05,0.08],[0.9,0.22,0.05],[1,0.85,0.2],[1,1,0.95] ],
ocean:[ [0,0,0],[0.02,0.05,0.18],[0.05,0.2,0.45],[0.15,0.55,0.78],[0.55,0.85,0.95],[0.95,1,1] ],
mono: [ [0,0,0],[0.08,0.08,0.09],[0.25,0.25,0.27],[0.55,0.55,0.58],[0.85,0.85,0.88],[1,1,1] ],
psychedelic:[ [0,0,0],[0.35,0.05,0.45],[0.05,0.55,0.95],[0.25,0.95,0.35],[0.95,0.9,0.1],[0.95,0.15,0.25],[0.35,0.05,0.45] ]
};
function paletteToRGBStops(name){
const stops = PALETTES[name];
return stops.map(([r,g,b])=>[Math.round(r*255),Math.round(g*255),Math.round(b*255)]);
}
/* ============================================================
STATE
============================================================ */
let view = { re: CONFIG.initCenterRe, im: CONFIG.initCenterIm, scale: CONFIG.initScale };
let juliaC = { re: -0.8, im: 0.156 };
let isJulia = false;
let paletteName = 'fire';
let paletteStops = paletteToRGBStops(paletteName);
let cycleOffset = 0; // 0..1
let smoothOn = true;
let autoIter = true;
let manualIter = 256;
let historyStack = [];
let hoverC = {re:0,im:0};
let animFrame = 0;
/* Canvas */
const canvas = document.getElementById('main');
const ctx = canvas.getContext('2d', {alpha:false});
const preview = document.getElementById('preview');
const pctx = preview.getContext('2d', {alpha:false});
const zoombox = document.getElementById('zoombox');
const cross = document.getElementById('cross');
function resize(){
const dpr = Math.min(window.devicePixelRatio||1, 2);
const w = canvas.clientWidth||window.innerWidth;
const h = canvas.clientHeight||window.innerHeight;
canvas.width = Math.round(w*dpr);
canvas.height = Math.round(h*dpr);
ctx.imageSmoothingEnabled = false;
// keep css size via style? canvas is absolute 100%
canvas.style.width = w+'px';
canvas.style.height = h+'px';
scheduleRender();
schedulePreview();
}
window.addEventListener('resize', resize);
/* ============================================================
MATH — allocation-free escape loop
============================================================ */
function iterationsAt(re, im, maxIter, useJulia=false, jRe=juliaC.re, jIm=juliaC.im){
let zr, zi, cr, ci;
if(useJulia){ // Julia: z0 = point, c = juliaC
zr=re; zi=im; cr=jRe; ci=jIm;
} else { // Mandelbrot: z0=0, c=point
zr=0; zi=0; cr=re; ci=im;
}
let zr2=0, zi2=0, iter=0;
for(; iter<maxIter; iter++){
// z = z^2 + c
// compute zi first using old zr
const nzr = zr2 - zi2 + cr;
const nzi = 2*zr*zi + ci;
zr = nzr; zi = nzi;
zr2 = zr*zr; zi2 = zi*zi;
if(zr2+zi2 > 4){
if(!smoothOn) return iter+1;
const mod = Math.sqrt(zr2+zi2);
// avoid log <=0
const log_zn = Math.log(mod);
// smooth = iter+1 - log2(log|z|)
const smooth = iter + 1 - Math.log(log_zn)/Math.log(2);
return smooth;
}
}
return maxIter; // interior
}
// for debug interface raw loop without smooth
function rawEscape(re,im,maxIter){
let zr=0,zi=0,zr2=0,zi2=0;
for(let i=0;i<maxIter;i++){
const nzr = zr2 - zi2 + re;
const nzi = 2*zr*zi + im;
zr=nzr; zi=nzi; zr2=zr*zr; zi2=zi*zi;
if(zr2+zi2>4) return i+1;
}
return maxIter;
}
function getMaxIter(){
if(!autoIter) return manualIter;
const mag = CONFIG.baseWidth / view.scale;
const extra = Math.max(0, Math.log2(mag)) * CONFIG.iterScaleFactor;
return Math.min(CONFIG.maxIterationsHard, Math.round(CONFIG.baseIterations + extra));
}
/* Mapping */
function pixelToComplex(x,y, w, h, v){
const pixelSize = v.scale / w;
const re = v.re + (x - w/2)*pixelSize;
const im = v.im + (y - h/2)*pixelSize;
return [re,im];
}
function complexToPixel(re,im,w,h,v){
const ps = v.scale / w;
return [(re - v.re)/ps + w/2, (im - v.im)/ps + h/2];
}
/* ============================================================
COLORIZING
============================================================ */
function lerp(a,b,t){return a + (b-a)*t}
function colorFor(t, stops, offset){
// t 0..1 (normalized smooth iter) + offset wrap
let u = (t + offset) % 1; if(u<0) u+=1;
const n = stops.length-1; // last is duplicate for mono? but we cycle
const scaled = u * n;
const i = Math.floor(scaled);
const f = scaled - i;
const c0 = stops[i], c1 = stops[Math.min(i+1,n)];
// interior black handled outside
return [ Math.round(lerp(c0[0],c1[0],f)), Math.round(lerp(c0[1],c1[1],f)), Math.round(lerp(c0[2],c1[2],f)) ];
}
function colorizeBuffer(iterBuf, w, h, maxIter, stops, offset, imageData){
const data = imageData.data;
const n = stops.length;
// pre avoid per-pixel allocation
for(let i=0;i<w*h;i++){
const it = iterBuf[i];
const idx = i*4;
if(it >= maxIter - 1e-6){ // interior
data[idx]=0; data[idx+1]=0; data[idx+2]=0; data[idx+3]=255;
} else {
// normalized: use smooth iter / maxIter but with periodicity for psychedelic: scale frequency
// Use log-ish mapping to keep detail: t = it / maxIter
let t = it / maxIter;
// add a slight power to boost contrast in low iter regions
// keep linear for smooth bands
if(paletteName==='psychedelic'){ t = (it * 0.07 + offset*2) % 1; // override offset handling later
// we will compute color differently
const u = t %1;
const scaled = u * (n-1);
const ii = Math.floor(scaled); const ff = scaled-ii;
const c0=stops[ii], c1=stops[Math.min(ii+1,n-1)];
data[idx]=Math.round(lerp(c0[0],c1[0],ff));
data[idx+1]=Math.round(lerp(c0[1],c1[1],ff));
data[idx+2]=Math.round(lerp(c0[2],c1[2],ff));
data[idx+3]=255;
} else {
const col = colorFor(t, stops, offset);
data[idx]=col[0]; data[idx+1]=col[1]; data[idx+2]=col[2]; data[idx+3]=255;
}
}
}
}
/* ============================================================
RENDERING PIPELINE — progressive, cancellable, chunked
============================================================ */
let renderGen = 0;
let fps = 0, lastFrame=performance.now(), frames=0;
let isRendering=false;
function scheduleRender(){
renderGen++;
const gen = renderGen;
isRendering=true;
const w = canvas.width, h = canvas.height;
const maxIter = getMaxIter();
document.getElementById('iterLbl').textContent = maxIter;
document.getElementById('iterVal').textContent = manualIter;
// update HUD readouts
const mag = CONFIG.baseWidth / view.scale;
document.getElementById('mag').textContent = mag < 1e4 ? mag.toFixed(2) : mag.toExponential(2);
document.getElementById('centerLbl').textContent = `${view.re.toFixed(6)} ${view.im>=0?'+':''}${view.im.toFixed(6)}i`;
const precLbl = document.getElementById('precLbl');
precLbl.style.display = mag > CONFIG.maxMagnification*0.8 ? 'inline-block' : 'none';
// create temp canvases for scaling
const steps = CONFIG.progressiveSteps;
let stepIdx=0;
function renderPass(){
if(gen!==renderGen) return;
const step = steps[stepIdx];
const lw = Math.ceil(w/step), lh = Math.ceil(h/step);
const iterBuf = new Float32Array(lw*lh);
const pixelSize = view.scale / w;
// chunked compute
let y=0;
const chunkRows = step===1 ? 12 : lh; // coarse does all at once, fine chunked
// For better responsiveness, split fine into chunks per rAF
function computeChunk(){
if(gen!==renderGen) return;
const endY = Math.min(y + chunkRows, lh);
const useJulia = isJulia;
const jRe=juliaC.re, jIm=juliaC.im;
// inner loops allocation-free
for(; y<endY; y++){
const im = view.im + ((y*step + step/2 - h/2))*pixelSize;
for(let x=0;x<lw;x++){
const re = view.re + ((x*step + step/2 - w/2))*pixelSize;
// inline iterationsAt to avoid function call overhead per pixel? keep call but okay
// manual inline for speed
let zr, zi, cr, ci;
if(useJulia){ zr=re; zi=im; cr=jRe; ci=jIm; } else { zr=0; zi=0; cr=re; ci=im; }
let zr2=0, zi2=0, iter=0;
let escaped=false; let smoothIter=0;
for(; iter<maxIter; iter++){
const nzr = zr2 - zi2 + cr;
const nzi = 2*zr*zi + ci;
zr=nzr; zi=nzi; zr2=zr*zr; zi2=zi*zi;
if(zr2+zi2>4){ escaped=true;
if(smoothOn){
const mod=Math.sqrt(zr2+zi2);
const lg=Math.log(mod);
smoothIter = iter + 1 - Math.log(lg)/Math.log(2);
} else smoothIter = iter+1;
break;
}
}
iterBuf[y*lw + x] = escaped ? smoothIter : maxIter;
}
}
if(y < lh){
requestAnimationFrame(computeChunk);
} else {
// colorize and blit
if(gen!==renderGen) return;
const img = new ImageData(lw,lh);
// for psychedelic we pass offset differently but colorize handles
colorizeBuffer(iterBuf, lw, lh, maxIter, paletteStops, cycleOffset, img);
if(step===1){
// direct put
// create full ImageData if lw==w else need to scale? when step 1 lw==w
ctx.putImageData(img,0,0);
// done all passes
isRendering=false;
frames++; // count
stepIdx++;
if(stepIdx < steps.length) requestAnimationFrame(renderPass);
else {
// final FPS update will be in rAF loop
}
} else {
// scale up via temp canvas
const tmp = document.createElement('canvas');
tmp.width=lw; tmp.height=lh;
const tctx=tmp.getContext('2d');
tctx.putImageData(img,0,0);
ctx.imageSmoothingEnabled=false;
ctx.drawImage(tmp,0,0,w,h);
// next pass
stepIdx++;
if(stepIdx < steps.length) requestAnimationFrame(renderPass);
else isRendering=false;
}
}
}
requestAnimationFrame(computeChunk);
}
renderPass();
saveHashDebounced();
}
let hashTimer=null;
function saveHashDebounced(){
clearTimeout(hashTimer);
hashTimer=setTimeout(saveHash,200);
}
function saveHash(){
const o={re:view.re, im:view.im, scale:view.scale, pal:paletteName, cyc:cycleOffset, julia:isJulia, jr:juliaC.re, ji:juliaC.im, mi: manualIter, au:autoIter};
location.hash = encodeURIComponent(JSON.stringify(o));
}
function loadHash(){
if(!location.hash) return false;
try{
const o=JSON.parse(decodeURIComponent(location.hash.slice(1)));
if(o.re!==undefined){ view.re=o.re; view.im=o.im; view.scale=o.scale; paletteName=o.pal||paletteName; paletteStops=paletteToRGBStops(paletteName); cycleOffset=o.cyc||0; isJulia=!!o.julia; juliaC.re=o.jr??juliaC.re; juliaC.im=o.ji??juliaC.im; manualIter=o.mi??manualIter; autoIter=o.au??autoIter; return true; }
}catch(e){}
return false;
}
/* Preview render (small, fast) */
let previewGen=0;
function schedulePreview(){
previewGen++;
const gen=previewGen;
const w=preview.width, h=preview.height;
const tmpView = isJulia ? view : {re:0, im:0, scale:3.2}; // Julia preview centered
// For hover preview, we need julia set for hoverC
const cRe = hoverC.re, cIm = hoverC.im;
document.getElementById('juliaCLabel').textContent = `c = ${cRe.toFixed(4)}${cIm>=0?'+':''}${cIm.toFixed(4)}i`;
const maxIter = 256;
const lw=w, lh=h;
const iterBuf = new Float32Array(lw*lh);
const pixelSize = tmpView.scale / w;
// quick full compute (small)
for(let y=0;y<lh;y++){
if(gen!==previewGen) return;
const im = tmpView.im + (y - lh/2)*pixelSize;
for(let x=0;x<lw;x++){
const re = tmpView.re + (x - lw/2)*pixelSize;
// julia: z0 = re+im*i, c = hoverC
let zr=re, zi=im, zr2=zr*zr, zi2=zi*zi;
let iter=0, smooth=0, escaped=false;
for(; iter<maxIter; iter++){
const nzr = zr2 - zi2 + cRe;
const nzi = 2*zr*zi + cIm;
zr=nzr; zi=nzi; zr2=zr*zr; zi2=zi*zi;
if(zr2+zi2>4){ escaped=true;
const mod=Math.sqrt(zr2+zi2); const lg=Math.log(mod);
smooth = iter+1 - Math.log(lg)/Math.log(2);
break;
}
}
iterBuf[y*lw+x]= escaped? smooth: maxIter;
}
}
const img=new ImageData(lw,lh);
colorizeBuffer(iterBuf,lw,lh,maxIter,paletteStops,cycleOffset,img);
pctx.putImageData(img,0,0);
}
/* ============================================================
INTERACTION
============================================================ */
let isPanning=false, isBoxing=false, startX=0,startY=0, lastX=0,lastY=0, panView=null;
const dpr =()=> canvas.width / (canvas.clientWidth||window.innerWidth);
canvas.addEventListener('mousedown', e=>{
if(e.button===2) return;
const rect=canvas.getBoundingClientRect();
const scale = dpr();
const x=(e.clientX-rect.left)*scale, y=(e.clientY-rect.top)*scale;
startX=x; startY=y; lastX=x; lastY=y;
if(e.shiftKey){
isBoxing=true;
zoombox.style.display='block';
zoombox.style.left=(x/scale)+'px'; zoombox.style.top=(y/scale)+'px';
zoombox.style.width='0px'; zoombox.style.height='0px';
} else {
isPanning=true;
panView={...view};
canvas.style.cursor='grabbing';
}
});
window.addEventListener('mousemove', e=>{
const rect=canvas.getBoundingClientRect();
const scale=dpr();
const x=(e.clientX-rect.left)*scale, y=(e.clientY-rect.top)*scale;
// hover handling for julia preview
const [re,im]=pixelToComplex(x,y,canvas.width,canvas.height,view);
hoverC.re=re; hoverC.im=im;
// crosshair
if(!isJulia){
cross.style.display='block';
cross.style.left=(e.clientX-rect.left-10)+'px';
cross.style.top=(e.clientY-rect.top-10)+'px';
}
// throttle preview
if(!isPanning && !isBoxing){
if(animFrame%6===0) schedulePreview();
}
if(isBoxing){
const sx=Math.min(startX,x), sy=Math.min(startY,y);
const sw=Math.abs(x-startX), sh=Math.abs(y-startY);
const zbs=zoombox.style;
zbs.left=(sx/scale)+'px'; zbs.top=(sy/scale)+'px';
zbs.width=(sw/scale)+'px'; zbs.height=(sh/scale)+'px';
} else if(isPanning){
const dx = x - startX, dy = y - startY;
const ps = panView.scale / canvas.width;
view.re = panView.re - dx*ps;
view.im = panView.im - dy*ps;
// live update with coarse render throttle
if(animFrame%3===0) scheduleRender();
}
lastX=x; lastY=y;
});
window.addEventListener('mouseup', e=>{
const rect=canvas.getBoundingClientRect();
const scale=dpr();
const x=(e.clientX-rect.left)*scale, y=(e.clientY-rect.top)*scale;
if(isBoxing){
isBoxing=false;
zoombox.style.display='none';
const sx=Math.min(startX,x), sy=Math.min(startY,y);
const sw=Math.abs(x-startX), sh=Math.abs(y-startY);
if(sw>8*scale && sh>8*scale){
// zoom to rectangle exactly
pushHistory();
// compute new view that contains exactly that rect, preserving aspect via scale = width in complex plane
// sx,sy,sw,sh in device pixels (canvas.width). Convert to complex rect
const ps = view.scale / canvas.width;
const re0 = view.re + (sx - canvas.width/2)*ps;
const re1 = view.re + (sx+sw - canvas.width/2)*ps;
const im0 = view.im + (sy - canvas.height/2)*ps;
const im1 = view.im + (sy+sh - canvas.height/2)*ps;
const selW = Math.abs(re1-re0);
const selH = Math.abs(im1-im0);
const selCenterRe = (re0+re1)/2;
const selCenterIm = (im0+im1)/2;
// aspect handling: we set scale to selW, but need to ensure selH fits vertically.
// canvas aspect = h/w. Required scaleH = selH * (canvas.width/canvas.height)
// We choose scale = max(selW, selH * canvas.width/canvas.height) to contain selection without shift.
const aspect = canvas.height / canvas.width;
const requiredScaleH = selH / aspect;
const newScale = Math.max(selW, requiredScaleH);
// clamp precision
const capped = Math.max(newScale, CONFIG.minScale);
view.re = selCenterRe;
view.im = selCenterIm;
view.scale = capped;
// Check precision cap warning
if(capped===CONFIG.minScale) toast("Max zoom reached (1e13×) — precision limit");
scheduleRender();
}
}
if(isPanning){
isPanning=false;
canvas.style.cursor='crosshair';
// if tiny drag treat as click for julia? only if not moved much
const dist=Math.hypot(x-startX,y-startY);
if(dist < 4*scale && !e.shiftKey){
// click selects Julia c if not in Julia mode; otherwise maybe recenter?
if(!isJulia){
const [cr,ci]=pixelToComplex(x,y,canvas.width,canvas.height,view);
juliaC.re=cr; juliaC.im=ci;
schedulePreview();
// subtle feedback
toast(`Julia c = ${cr.toFixed(5)} ${ci>=0?'+':''}${ci.toFixed(5)}i — press J to open`);
}
} else {
// pan settled, already scheduled
scheduleRender();
}
}
});
canvas.addEventListener('wheel', e=>{
e.preventDefault();
const rect=canvas.getBoundingClientRect();
const scale=dpr();
const mx=(e.clientX-rect.left)*scale, my=(e.clientY-rect.top)*scale;
const [cre,cim]=pixelToComplex(mx,my,canvas.width,canvas.height,view);
const factor = Math.exp(-e.deltaY * CONFIG.zoomSpeed);
// clamp
let newScale = view.scale / factor;
newScale = Math.max(CONFIG.minScale, Math.min(CONFIG.baseWidth*2, newScale));
if(newScale===CONFIG.minScale && factor>1) { toast("Max zoom (1e13×)"); return; }
pushHistoryIfNeeded();
// zoom toward cursor
const psNew = newScale / canvas.width;
view.re = cre - (mx - canvas.width/2)*psNew;
view.im = cim - (my - canvas.height/2)*psNew;
view.scale = newScale;
scheduleRender();
},{passive:false});
canvas.addEventListener('contextmenu', e=>e.preventDefault());
/* Keyboard */
window.addEventListener('keydown', e=>{
if(e.target.tagName==='INPUT') return;
const step = view.scale * CONFIG.panStep;
let handled=true;
switch(e.key){
case 'ArrowLeft': pushHistory(); view.re-=step; break;
case 'ArrowRight': view.re+=step; break;
case 'ArrowUp': view.im-=step; break;
case 'ArrowDown': view.im+=step; break;
case '+': case '=': pushHistory(); view.scale/=CONFIG.keyZoomFactor; view.scale=Math.max(CONFIG.minScale,view.scale); break;
case '-': case '_': pushHistory(); view.scale*=CONFIG.keyZoomFactor; break;
case 'j': case 'J': toggleJulia(); break;
case 'r': case 'R': resetView(); break;
case 'b': case 'B': goBack(); break;
default: handled=false;
}
if(handled){ e.preventDefault(); scheduleRender(); }
});
/* History */
function pushHistory(){
historyStack.push({re:view.re, im:view.im, scale:view.scale});
if(historyStack.length>40) historyStack.shift();
}
let lastPush=0;
function pushHistoryIfNeeded(){
if(performance.now()-lastPush>300){ pushHistory(); lastPush=performance.now(); }
}
function goBack(){
if(!historyStack.length) return toast("No history");
const v=historyStack.pop();
view.re=v.re; view.im=v.im; view.scale=v.scale;
scheduleRender();
}
function resetView(){
pushHistory();
view.re=CONFIG.initCenterRe; view.im=CONFIG.initCenterIm; view.scale=CONFIG.initScale;
isJulia=false; updateJuliaBtn();
scheduleRender();
}
/* Julia toggle */
function toggleJulia(){
if(!isJulia){
// open with current hover/selected c
pushHistory();
isJulia=true;
// keep view center but adjust scale if needed
// optionally center julia view at 0,0 scale 3.2
view.re=0; view.im=0; view.scale=3.2;
} else {
pushHistory();
isJulia=false;
view.re=CONFIG.initCenterRe; view.im=CONFIG.initCenterIm; view.scale=CONFIG.initScale;
}
updateJuliaBtn();
scheduleRender();
schedulePreview();
}
function updateJuliaBtn(){
const btn=document.getElementById('juliaToggle');
btn.textContent = isJulia ? `Julia: ON (c ${juliaC.re.toFixed(2)}${juliaC.im>=0?'+':''}${juliaC.im.toFixed(2)}i)` : 'Julia: OFF';
btn.classList.toggle('primary', isJulia);
}
/* Controls */
function initControls(){
const swWrap=document.getElementById('swatches');
Object.keys(PALETTES).forEach(name=>{
const d=document.createElement('button');
d.className='swatch'+(name===paletteName?' active':'');
d.title=name;
const stops=paletteToRGBStops(name);
// gradient preview
d.style.background=`linear-gradient(90deg, ${stops.map(c=>`rgb(${c[0]},${c[1]},${c[2]})`).join(',')})`;
d.addEventListener('click',()=>{
paletteName=name; paletteStops=paletteToRGBStops(name);
document.querySelectorAll('.swatch').forEach(s=>s.classList.remove('active'));
d.classList.add('active');
scheduleRender(); schedulePreview();
});
swWrap.appendChild(d);
});
document.getElementById('cycle').addEventListener('input', e=>{
cycleOffset = e.target.value/100;
document.getElementById('cycleVal').textContent=e.target.value;
// recolor only
scheduleRender(); schedulePreview();
});
document.getElementById('smoothChk').addEventListener('change', e=>{ smoothOn=e.target.checked; scheduleRender(); schedulePreview(); });
document.getElementById('iterSlider').addEventListener('input', e=>{
manualIter=parseInt(e.target.value); document.getElementById('iterVal').textContent=manualIter;
if(!autoIter) scheduleRender();
});
document.getElementById('autoIterChk').addEventListener('change', e=>{
autoIter=e.target.checked; document.getElementById('autoIterVal').textContent=autoIter?'on':'off';
document.getElementById('iterSlider').disabled=autoIter;
scheduleRender();
});
document.getElementById('backBtn').addEventListener('click', goBack);
document.getElementById('resetBtn').addEventListener('click', resetView);
document.getElementById('juliaToggle').addEventListener('click', toggleJulia);
document.getElementById('swapJulia').addEventListener('click', toggleJulia);
document.getElementById('zoominBtn').addEventListener('click', ()=>{pushHistory(); view.scale/=1.4; view.scale=Math.max(CONFIG.minScale,view.scale); scheduleRender();});
document.getElementById('zoomoutBtn').addEventListener('click', ()=>{pushHistory(); view.scale*=1.4; scheduleRender();});
document.getElementById('locateSeahorse').addEventListener('click', ()=>{
pushHistory();
// seahorse valley ~ -0.74364 +0.131825i
view.re=-0.743643887037151; view.im=0.13182590420533; view.scale=0.0008;
scheduleRender();
});
document.getElementById('shareBtn').addEventListener('click', ()=>{
saveHash(); navigator.clipboard.writeText(location.href).then(()=>toast("Link copied!"),()=>toast("Link: "+location.href));
});
document.getElementById('saveBm').addEventListener('click', saveBookmark);
}
function toast(msg){
const t=document.getElementById('toast');
t.textContent=msg; t.style.display='block';
clearTimeout(t._tm); t._tm=setTimeout(()=>t.style.display='none',2200);
}
/* Bookmarks localStorage */
function loadBookmarks(){
try{ return JSON.parse(localStorage.getItem('fractalBookmarks')||'[]'); }catch{return []}
}
function saveBookmarks(list){ localStorage.setItem('fractalBookmarks', JSON.stringify(list)); renderBookmarks(); }
function saveBookmark(){
const name=document.getElementById('bmName').value.trim()||`View ${new Date().toLocaleTimeString()}`;
const list=loadBookmarks();
list.unshift({name, view:{...view}, palette:paletteName, cycle:cycleOffset, isJulia, juliaC:{...juliaC}, manualIter, autoIter, date:Date.now()});
if(list.length>20) list.pop();
document.getElementById('bmName').value='';
saveBookmarks(list);
toast("Bookmark saved");
}
function renderBookmarks(){
const list=loadBookmarks();
const wrap=document.getElementById('bmList');
wrap.innerHTML='';
list.forEach((b,i)=>{
const div=document.createElement('div'); div.className='bm';
div.innerHTML=`<div><b>${b.name}</b><br><small>${b.view.re.toFixed(4)} ${b.view.im>=0?'+':''}${b.view.im.toFixed(4)}i ×${(CONFIG.baseWidth/b.view.scale).toExponential(1)} ${b.isJulia?' Julia':''}</small></div>`;
const row=document.createElement('div'); row.style.display='flex'; row.style.gap='6px';
const load=document.createElement('button'); load.className='btn'; load.textContent='Load'; load.onclick=()=>{
view.re=b.view.re; view.im=b.view.im; view.scale=b.view.scale;
paletteName=b.palette; paletteStops=paletteToRGBStops(paletteName);
cycleOffset=b.cycle; document.getElementById('cycle').value=Math.round(cycleOffset*100); document.getElementById('cycleVal').textContent=Math.round(cycleOffset*100);
isJulia=b.isJulia; juliaC.re=b.juliaC.re; juliaC.im=b.juliaC.im;
manualIter=b.manualIter; autoIter=b.autoIter;
document.getElementById('autoIterChk').checked=autoIter;
document.getElementById('iterSlider').value=manualIter;
document.getElementById('iterSlider').disabled=autoIter;
// refresh swatch active
document.querySelectorAll('.swatch').forEach((s,idx)=> s.classList.toggle('active', Object.keys(PALETTES)[idx]===paletteName));
updateJuliaBtn();
scheduleRender(); schedulePreview();
toast("Bookmark restored — identical view");
};
const del=document.createElement('button'); del.className='btn'; del.textContent='✕'; del.onclick=()=>{
list.splice(i,1); saveBookmarks(list);
};
row.append(load,del); div.append(row); wrap.append(div);
});
if(!list.length) wrap.innerHTML='<div class="hint">No bookmarks yet. Save the current view.</div>';
}
/* ============================================================
DEBUG INTERFACE on window
============================================================ */
window.fractal = {
get view(){ return {...view}; },
get juliaC(){return {...juliaC}},
get isJulia(){return isJulia},
get palette(){return paletteName},
iterationsAt: (re,im, maxIter=getMaxIter(), useJulia=false)=> iterationsAt(re,im,maxIter,useJulia,juliaC.re,juliaC.im),
rawEscape,
setView(o){ if(o.re!==undefined) view.re=o.re; if(o.im!==undefined) view.im=o.im; if(o.scale!==undefined) view.scale=o.scale; if(o.julia!==undefined) isJulia=o.julia; if(o.jr!==undefined) juliaC.re=o.jr; if(o.ji!==undefined) juliaC.im=o.ji; if(o.palette) {paletteName=o.palette; paletteStops=paletteToRGBStops(paletteName);} scheduleRender(); schedulePreview(); },
getView: ()=> ({...view, scale:view.scale, mag:CONFIG.baseWidth/view.scale, maxIter:getMaxIter(), palette:paletteName, julia:isJulia, juliaC:{...juliaC}}),
render: ()=> scheduleRender(),
_config: CONFIG
};
/* ============================================================
INIT
============================================================ */
initControls();
renderBookmarks();
if(!loadHash()){
// no hash
}
updateJuliaBtn();
resize();
schedulePreview();
// FPS loop
function fpsLoop(){
animFrame++;
const now=performance.now();
if(now - lastFrame > CONFIG.fpsSample){
fps = Math.round(frames * 1000 / (now - lastFrame));
document.getElementById('fpsLbl').textContent = fps;
frames=0; lastFrame=now;
}
requestAnimationFrame(fpsLoop);
}
fpsLoop();
/* Hide HUD when idle (elegant) — fade after 3s no interaction */
let idleTimer=null;
function bumpIdle(){ document.getElementById('leftpanel').style.opacity='1'; document.getElementById('topbar').style.opacity='1'; clearTimeout(idleTimer); idleTimer=setTimeout(()=>{ if(!isPanning && !isBoxing){ document.getElementById('leftpanel').style.opacity='0.18'; }},3000); }
['mousemove','mousedown','keydown','wheel'].forEach(ev=> window.addEventListener(ev,bumpIdle));
bumpIdle();
/* Self-check console hints */
console.log('%cFractal Explorer ready','color:#7cf8d6;font-weight:700');
console.log('Try: fractal.iterationsAt(0,0) →', window.fractal.iterationsAt(0,0));
console.log(' fractal.iterationsAt(2,0) →', window.fractal.iterationsAt(2,0));
console.log(' fractal.iterationsAt(-1,0) interior →', window.fractal.iterationsAt(-1,0));
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2","provider":"openrouter","persona":"basic","sessionId":"7d449a3d-4e77-42b4-b736-e8494f539ee7","tokensIn":1189,"tokensOut":14098,"tokensTotal":15287,"cost":0.06140275,"turns":1,"toolCalls":0,"failedToolCalls":0,"timestamp":"2026-08-06T15:09:57.929Z"} -->