Fractal Explorer
muse spark 1.2 contributor · full

Browser check: Artifact runtime error. This is a technical check, not a quality review.
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 47,700 bytes · SHA-256 242991271293
<!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>
*{margin:0;padding:0;box-sizing:border-box}
:root{--glass:rgba(18,18,24,0.72);--glass2:rgba(28,28,38,0.55);--bdr:rgba(255,255,255,0.12);--accent:#7c5cff;--text:#e8e8ef;--muted:#9aa0b2}
html,body{width:100%;height:100%;overflow:hidden;background:#06070a;color:var(--text);font-family: ui-sans-system, -apple-system, Segoe UI, Roboto, Inter, sans-serif}
#wrap{position:relative;width:100%;height:100vh;overflow:hidden}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
#mainCanvas{z-index:1;cursor:crosshair}
#overlayCanvas{z-index:2;pointer-events:none}
/* HUD */
#hud{position:absolute;top:14px;left:50%;transform:translateX(-50%);z-index:10;display:flex;gap:10px;align-items:center;
background:#1a1a24;border:1px solid rgba(255,255,255,0.18);border-radius:999px;padding:8px 14px 8px 16px;
box-shadow:0 8px 32px rgba(0,0,0,0.65);transition:opacity .4s, transform .4s;white-space:nowrap}
#hud.idle{opacity:0.92;transform:translateX(-50%) translateY(-2px)}
#hud:hover{opacity:1}
.hud-sep{width:1px;height:22px;background:var(--bdr);margin:0 2px}
.hud-item{display:flex;flex-direction:column;line-height:1}
.hud-label{font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);font-weight:700}
.hud-value{font-size:13px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.02em}
.hud-value small{font-weight:400;color:var(--muted)}
#fps{color:#7CFF9A}
/* Left Panel */
#panel{position:absolute;left:14px;top:14px;bottom:14px;z-index:9;width:300px;display:flex;flex-direction:column;gap:12px;pointer-events:none}
#panel > *{pointer-events:auto}
.glass{background:var(--glass);backdrop-filter:blur(16px) saturate(1.35);border:1px solid var(--bdr);border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
#titleCard{padding:16px 16px 14px}
#titleCard h1{font-size:15px;letter-spacing:-.02em;display:flex;align-items:center;gap:8px}
#titleCard h1 i{width:26px;height:26px;border-radius:8px;background:linear-gradient(135deg,#7c5cff,#ff5c9a);display:grid;place-items:center;font-style:normal;font-size:13px}
#titleCard p{font-size:11.5px;color:var(--muted);margin-top:6px;line-height:1.45}
.panel-section{padding:14px 14px 12px}
.panel-section h3{font-size:10px;letter-spacing:.11em;text-transform:uppercase;color:var(--muted);margin-bottom:10px;display:flex;align-items:center;justify-content:space-between}
.chips{display:flex;gap:6px;flex-wrap:wrap}
.chip{flex:1 1 calc(50% - 6px);min-width:0;padding:8px 6px;border-radius:10px;border:1px solid var(--bdr);background:rgba(255,255,255,0.06);cursor:pointer;display:flex;align-items:center;gap:8px;transition:.15s}
.chip.active{border-color:var(--accent);background:rgba(124,92,255,0.18);box-shadow:0 0 0 3px rgba(124,92,255,0.15)}
.chip:hover{transform:translateY(-1px);background:rgba(255,255,255,0.09)}
.swatch{width:28px;height:18px;border-radius:6px;border:1px solid rgba(255,255,255,0.18);flex-shrink:0}
.chip span{font-size:12px;font-weight:600;white-space:nowrap}
.row{display:flex;gap:8px;align-items:center}
.btn{appearance:none;border:1px solid var(--bdr);background:rgba(255,255,255,0.08);color:var(--text);padding:7px 12px;border-radius:999px;font-size:12px;font-weight:600;cursor:pointer;transition:.15s;display:inline-flex;align-items:center;gap:6px;justify-content:center}
.btn:hover{background:rgba(255,255,255,0.14);transform:translateY(-1px)}
.btn.primary{background:var(--accent);border-color:transparent;color:white;box-shadow:0 6px 18px rgba(124,92,255,0.35)}
.btn.primary:hover{background:#6e4df0}
.btn.small{padding:5px 10px;font-size:11px}
.btn:disabled{opacity:.45;pointer-events:none}
.slider-row{display:flex;align-items:center;gap:10px;margin:8px 0}
.slider-row label{font-size:11px;color:var(--muted);min-width:72px}
.slider-row input[type=range]{flex:1;accent-color:var(--accent);height:4px}
.slider-row output{font-size:11px;font-variant-numeric:tabular-nums;min-width:44px;text-align:right;color:var(--text);font-weight:600}
.toggle{display:flex;align-items:center;gap:8px;font-size:12px;cursor:pointer;user-select:none}
.toggle input{accent-color:var(--accent)}
.coords{font-family: ui-monospace, SFMono-Regular, Menlo, monospace;font-size:11px;color:var(--muted);line-height:1.6;word-break:break-all}
.coords b{color:var(--text)}
/* Julia preview */
#juliaPreview{position:absolute;right:14px;top:14px;z-index:9;width:220px;background:var(--glass);backdrop-filter:blur(16px);border:1px solid var(--bdr);border-radius:16px;overflow:hidden;box-shadow:0 8px 32px rgba(0,0,0,0.4);display:none}
#juliaPreview.visible{display:block}
#juliaPreview canvas{position:relative;width:100%;height:220px;display:block}
#juliaPreview header{padding:10px 12px 8px;display:flex;align-items:center;justify-content:space-between}
#juliaPreview header h4{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
#juliaPreview header button{font-size:10px}
#juliaPreview .meta{padding:6px 12px 10px;font-size:11px;color:var(--muted);font-family:ui-monospace,monospace}
/* Bookmarks */
#bookmarks{max-height:140px;overflow:auto;display:flex;flex-direction:column;gap:6px}
.bm{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:10px;background:rgba(255,255,255,0.06);border:1px solid transparent}
.bm:hover{border-color:var(--bdr);background:rgba(255,255,255,0.09)}
.bm-name{flex:1;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bm-meta{font-size:10px;color:var(--muted)}
/* Bottom hint */
#hint{position:absolute;bottom:14px;left:50%;transform:translateX(-50%);z-index:9;background:var(--glass);backdrop-filter:blur(12px);border:1px solid var(--bdr);border-radius:999px;padding:7px 14px;font-size:11px;color:var(--muted);display:flex;gap:14px;align-items:center;box-shadow:0 8px 32px rgba(0,0,0,0.35)}
#hint kbd{background:rgba(255,255,255,0.12);border:1px solid rgba(255,255,255,0.15);border-bottom-width:2px;padding:1px 5px;border-radius:5px;font-family:ui-monospace,monospace;font-size:10px;color:var(--text)}
/* Mobile */
@media (max-width: 860px){
#panel{width:260px;top:64px}
#hud{top:8px;padding:6px 10px;gap:6px}
.hud-label{font-size:8px}
.hud-value{font-size:11px}
#juliaPreview{width:160px;right:8px;top:60px}
#juliaPreview canvas{height:160px}
#hint{display:none}
}
/* Scrollbar */
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.15);border-radius:999px}
</style>
</head>
<body>
<div id="wrap">
<canvas id="mainCanvas"></canvas>
<canvas id="overlayCanvas"></canvas>
<div id="hud">
<div class="hud-item"><span class="hud-label">Magnification</span><span class="hud-value" id="hudZoom">×1.0</span></div>
<div class="hud-sep"></div>
<div class="hud-item"><span class="hud-label">Center</span><span class="hud-value" id="hudCenter" style="font-family:ui-monospace,monospace;font-size:11px">-0.50 + 0.00i</span></div>
<div class="hud-sep"></div>
<div class="hud-item"><span class="hud-label">Iterations</span><span class="hud-value" id="hudIters">256</span></div>
<div class="hud-sep"></div>
<div class="hud-item"><span class="hud-label">FPS</span><span class="hud-value" id="fps">—</span></div>
<div class="hud-sep"></div>
<div class="hud-item"><span class="hud-label">Mode</span><span class="hud-value" id="hudMode" style="font-size:11px">Mandelbrot</span></div>
</div>
<div id="panel">
<div class="glass" id="titleCard">
<h1><i>◈</i> Fractal Explorer</h1>
<p>Deep-zoom Mandelbrot & Julia sets with smooth coloring. Drag a rectangle to zoom, scroll to zoom toward cursor, drag with <b style="color:var(--text)">right-button</b> or <b style="color:var(--text)">Space</b> to pan.</p>
<div class="row" style="margin-top:10px">
<button class="btn small" id="btnBack" title="Back (previous view)">↩ Back</button>
<button class="btn small" id="btnReset" title="Reset view">⟲ Reset</button>
<button class="btn small primary" id="btnJuliaToggle" title="Press J">Julia: Off</button>
</div>
</div>
<div class="glass panel-section">
<h3>Palette <span id="paletteName" style="text-transform:none;letter-spacing:0;color:var(--text);font-size:11px">Inferno</span></h3>
<div class="chips" id="paletteChips"></div>
<div class="slider-row"><label>Cycle</label><input type="range" id="cycleSlider" min="0" max="100" value="0"><output id="cycleOut">0%</output></div>
<label class="toggle"><input type="checkbox" id="smoothToggle" checked> Smooth coloring</label>
<label class="toggle" style="margin-top:6px"><input type="checkbox" id="previewToggle" checked> Live Julia preview on hover</label>
</div>
<div class="glass panel-section">
<h3>Render <span id="depthWarn" style="color:#ff6b6b;display:none;font-size:10px">⚠ Max depth</span></h3>
<div class="slider-row"><label>Iterations</label><input type="range" id="iterSlider" min="64" max="2048" step="32" value="256"><output id="iterOut">256</output></div>
<label class="toggle"><input type="checkbox" id="autoIterToggle" checked> Auto-scale with zoom</label>
<div class="slider-row"><label>Julia Re</label><input type="range" id="juliaRe" min="-2" max="2" step="0.001" value="-0.8"><output id="juliaReOut">-0.800</output></div>
<div class="slider-row"><label>Julia Im</label><input type="range" id="juliaIm" min="-2" max="2" step="0.001" value="0.156"><output id="juliaImOut">0.156</output></div>
<div class="row" style="margin-top:8px">
<button class="btn small" id="btnSeahorse">★ Seahorse Valley</button>
<button class="btn small" id="btnElephant">★ Elephant</button>
</div>
</div>
<div class="glass panel-section">
<h3>Bookmarks <button class="btn small" id="btnSaveBm" style="padding:3px 8px">+ Save</button></h3>
<div id="bookmarks"></div>
<div class="row" style="margin-top:8px">
<button class="btn small" id="btnShare">Copy Link</button>
<button class="btn small" id="btnClearBm">Clear all</button>
</div>
<div class="coords" style="margin-top:10px" id="coordReadout"></div>
</div>
</div>
<div id="juliaPreview">
<header><h4>Julia Preview</h4><button class="btn small" id="btnEnterJulia">Enter →</button></header>
<canvas id="previewCanvas" width="220" height="220"></canvas>
<div class="meta" id="previewMeta">c = -0.800 + 0.156i</div>
</div>
<div id="hint">
<span><kbd>Drag</kbd> rectangle zoom</span>
<span><kbd>Scroll</kbd> zoom to cursor</span>
<span><kbd>Right-drag</kbd> pan</span>
<span><kbd>J</kbd> Julia</span>
<span><kbd>H</kbd> hide UI</span>
<span><kbd>←→↑↓</kbd> pan</span>
<span><kbd>+</kbd><kbd>−</kbd> zoom</span>
</div>
</div>
<script>
// ============================================================
// TUNABLE CONSTANTS — edit these to tune behavior
// ============================================================
const CONFIG = {
// view
initialCenter: { re: -0.5, im: 0 },
initialScale: null, // computed from canvas width (complex units per pixel)
maxMagnification: 1e13, // ~ double precision limit
minScale: 1e-13, // derived from maxMag
zoomSpeed: 0.12, // wheel zoom factor per notch (0.12 = ~12% per tick)
// iterations
baseIterations: 256,
iterScaleFactor: 0.18, // log2(mag) * factor added to base
maxIterationsCap: 2048,
minIterations: 64,
// rendering
progressive: true,
progressiveSteps: [4, 2, 1], // coarse to fine
chunkRows: 32, // rows per yield to keep UI responsive
useWorkers: false, // set true to enable Blob workers (simple tile workers)
workerCount: 4,
// palettes
palettes: {
inferno: { name:'Inferno', stops:[
{t:0, c:[10,5,40]}, {t:0.18,c:[60,20,120]}, {t:0.32,c:[140,30,160]}, {t:0.48,c:[210,50,110]}, {t:0.62,c:[255,110,40]}, {t:0.76,c:[255,190,50]}, {t:0.88,c:[255,230,120]}, {t:1,c:[255,255,220]}
]},
ocean: { name:'Ocean', stops:[
{t:0,c:[2,8,30]}, {t:0.2,c:[4,30,80]}, {t:0.4,c:[10,90,140]}, {t:0.6,c:[30,160,180]}, {t:0.8,c:[120,220,220]}, {t:1,c:[240,255,255]}
]},
monochrome: { name:'Monochrome', stops:[
{t:0,c:[8,8,10]}, {t:0.25,c:[40,40,45]}, {t:0.5,c:[110,110,115]}, {t:0.75,c:[190,190,195]}, {t:1,c:[255,255,255]}
]},
psychedelic: { name:'Psychedelic', stops:[
{t:0,c:[255,20,80]}, {t:0.17,c:[255,180,0]}, {t:0.33,c:[40,255,120]}, {t:0.5,c:[30,140,255]}, {t:0.67,c:[140,40,255]}, {t:0.83,c:[255,40,180]}, {t:1,c:[255,20,80]}
]},
ember: { name:'Ember', stops:[
{t:0,c:[10,0,0]}, {t:0.3,c:[120,20,0]}, {t:0.55,c:[220,80,0]}, {t:0.75,c:[255,180,40]}, {t:1,c:[255,255,200]}
]}
},
defaultPalette: 'inferno',
// seahorse valley etc
presets: {
seahorse: { re: -0.743643887037151, im: 0.13182590420533, scale: 0.000004 },
elephant: { re: 0.274226, im: 0.00742, scale: 0.000008 },
cusp: { re: 0.25, im: 0, scale: 0.002 }
}
};
// ============================================================
// STATE
// ============================================================
const els = {};
let canvas, ctx, overlay, octx, previewCanvas, pctx;
let W=0,H=0,dpr=1;
let view = { re: CONFIG.initialCenter.re, im: CONFIG.initialCenter.im, scale: 0.0035 }; // complex units per pixel
let historyStack = [];
let renderId = 0;
let isJulia = false;
let juliaC = { re: -0.8, im: 0.156 };
let hoverC = { re: -0.8, im: 0.156 };
let paletteKey = CONFIG.defaultPalette;
let paletteOffset = 0; // 0..1
let smoothColor = true;
let autoIter = true;
let manualIter = CONFIG.baseIterations;
let animFrame = 0;
let lastFpsTime = performance.now(), frames=0, fpsVal=0;
let hudIdleTimer=null;
let showUI=true;
let isDragging=false, dragStart=null, dragCurrent=null, isPanning=false, panStart=null, panViewStart=null;
let spaceHeld=false;
let previewEnabled=true;
// buffers
let iterBuffer = null; // Float32Array W*H
let imageData = null;
// ============================================================
// MATH — allocation-free iteration
// ============================================================
function mandelbrotIter(cr, ci, maxIter){
let zr=0, zi=0, zr2=0, zi2=0;
let iter=0;
// cardioid/bulb early exit optimization (optional but speeds interior)
// main cardioid check
let q = (cr - 0.25)*(cr - 0.25) + ci*ci;
if (q*(q + (cr - 0.25)) < 0.25*ci*ci) return maxIter;
// period-2 bulb
if ((cr+1)*(cr+1) + ci*ci < 0.0625) return maxIter;
while (iter < maxIter && (zr2 + zi2) <= 4){
zi = 2*zr*zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr*zr;
zi2 = zi*zi;
iter++;
}
if (iter === maxIter) return maxIter;
if (!smoothColor) return iter;
// smooth coloring: mu = n + 1 - log2(log|z|)
// |z| = sqrt(zr2+zi2)
let log_zn = Math.log(zr2 + zi2) * 0.5;
if (log_zn <= 0) return iter;
let nu = Math.log(log_zn / Math.log(2)) / Math.log(2);
let mu = iter + 1 - nu;
if (mu < 0) mu = iter;
if (mu > maxIter) mu = maxIter;
return mu;
}
function juliaIter(zr0, zi0, cr, ci, maxIter){
let zr=zr0, zi=zi0;
let zr2=zr*zr, zi2=zi*zi;
let iter=0;
while (iter < maxIter && (zr2+zi2) <= 4){
let nzr = zr2 - zi2 + cr;
let nzi = 2*zr*zi + ci;
zr=nzr; zi=nzi;
zr2=zr*zr; zi2=zi*zi;
iter++;
}
if (iter===maxIter) return maxIter;
if (!smoothColor) return iter;
let log_zn = Math.log(zr2+zi2)*0.5;
if (log_zn <=0) return iter;
let nu = Math.log(log_zn/Math.log(2))/Math.log(2);
let mu = iter + 1 - nu;
return mu;
}
function iterationsAt(re, im){
const maxI = getCurrentMaxIter();
return mandelbrotIter(re, im, maxI);
}
function juliaIterationsAt(re, im){
const maxI = getCurrentMaxIter();
return juliaIter(re, im, juliaC.re, juliaC.im, maxI);
}
// ============================================================
// VIEW HELPERS
// ============================================================
function getCurrentMaxIter(){
if (!autoIter) return manualIter;
const initialScale = CONFIG.initialScale || 0.0035;
const mag = initialScale / view.scale;
const extra = Math.log2(Math.max(1, mag)) * 18; // ~18 per doubling
let it = CONFIG.baseIterations + extra;
it = Math.max(CONFIG.minIterations, Math.min(CONFIG.maxIterationsCap, Math.round(it)));
return it;
}
function magnification(){
const init = CONFIG.initialScale || 0.0035;
return init / view.scale;
}
function pixelToComplex(px, py){
// px,py in canvas CSS pixels (not device pixels) — we use W,H as device pixels, but view.scale is per device pixel?
// Let's define view.scale as complex units per CSS pixel? Simpler per device pixel? We'll use per CSS pixel for intuitive.
// Actually we set W = canvas.width (device pixels), but we want mapping consistent. Use CSS size.
const cssW = canvas.clientWidth, cssH = canvas.clientHeight;
const re = view.re + (px - cssW/2) * view.scale;
const im = view.im - (py - cssH/2) * view.scale; // y inverted
return {re, im};
}
function complexToPixel(re, im){
const cssW = canvas.clientWidth, cssH = canvas.clientHeight;
const px = (re - view.re)/view.scale + cssW/2;
const py = (view.im - im)/view.scale + cssH/2;
return {x:px, y:py};
}
function 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.juliaRe!==undefined) juliaC.re=o.juliaRe;
if (o.juliaIm!==undefined) juliaC.im=o.juliaIm;
if (o.palette) paletteKey=o.palette;
if (o.paletteOffset!==undefined) paletteOffset=o.paletteOffset;
clampView();
scheduleRender();
updateHUD();
updateHash();
}
function getView(){
return { re: view.re, im: view.im, scale: view.scale, magnification: magnification(), maxIter:getCurrentMaxIter(), palette:paletteKey, paletteOffset, isJulia, juliaC:{...juliaC} };
}
function clampView(){
const mag = magnification();
if (mag > CONFIG.maxMagnification){
view.scale = (CONFIG.initialScale||0.0035)/CONFIG.maxMagnification;
}
}
function pushHistory(){
historyStack.push({re:view.re, im:view.im, scale:view.scale});
if (historyStack.length>64) historyStack.shift();
updateBackButton();
}
function popHistory(){
if (!historyStack.length) return;
const prev = historyStack.pop();
view.re=prev.re; view.im=prev.im; view.scale=prev.scale;
updateBackButton();
scheduleRender(); updateHUD();
}
function resetView(){
pushHistory();
view.re=CONFIG.initialCenter.re; view.im=CONFIG.initialCenter.im;
view.scale=CONFIG.initialScale||0.0035;
scheduleRender(); updateHUD();
}
// ============================================================
// COLORING
// ============================================================
function buildPaletteLUT(size=1024){
const pal = CONFIG.palettes[paletteKey];
if (!pal) return null;
const lut = new Uint8Array(size*3);
const stops = pal.stops;
for(let i=0;i<size;i++){
let t = (i/size + paletteOffset) % 1;
// find segment
let seg=0;
for(let s=0;s<stops.length-1;s++){
if (t >= stops[s].t && t <= stops[s+1].t){ seg=s; break; }
if (s===stops.length-2) seg=s;
}
const a=stops[seg], b=stops[seg+1];
const span = b.t - a.t || 1;
let f = (t - a.t)/span;
// handle wrap for last segment
if (t < a.t || t > b.t){
// for psychedelic wrap, interpolate across boundary
// find correct by brute
// fallback linear search for t
for(let s=0;s<stops.length-1;s++){
if (t>=stops[s].t && t<=stops[s+1].t){ seg=s; break; }
}
}
// smoothstep for nicer
// f = f*f*(3-2*f);
const r = Math.round(a.c[0] + (b.c[0]-a.c[0])*f);
const g = Math.round(a.c[1] + (b.c[1]-a.c[1])*f);
const b2 = Math.round(a.c[2] + (b.c[2]-a.c[2])*f);
lut[i*3]=r; lut[i*3+1]=g; lut[i*3+2]=b2;
}
return lut;
}
let paletteLUT=null, lutSize=1024;
function updateLUT(){ paletteLUT = buildPaletteLUT(lutSize); }
// ============================================================
// RENDERING PIPELINE — progressive, chunked, cancelable
// ============================================================
function ensureBuffers(){
const w = canvas.width, h = canvas.height;
if (!iterBuffer || iterBuffer.length !== w*h){
iterBuffer = new Float32Array(w*h);
imageData = ctx.createImageData(w,h);
}
}
function colorizeAndBlit(maxIter){
const w=canvas.width, h=canvas.height;
const data = imageData.data;
const lut = paletteLUT;
// interior = black
for(let i=0;i<w*h;i++){
const mu = iterBuffer[i];
const idx=i*4;
if (mu >= maxIter - 0.5){
data[idx]=0; data[idx+1]=0; data[idx+2]=0; data[idx+3]=255;
} else {
let t;
if (paletteKey==='psychedelic'){
t = (mu * 0.09 + paletteOffset) % 1;
} else {
// ensure μ≈2 maps to ~40% of palette (bright mid-tones)
let norm = mu / maxIter;
// strong lift for low μ: linear blend between sqrt and log
norm = 0.65 * Math.pow(norm, 0.32) + 0.35 * (Math.log(1+mu*4)/Math.log(1+maxIter*4));
t = (norm*0.68 + 0.30 + paletteOffset) % 1;
}
if (t<0) t+=1;
let li = Math.floor(t*(lutSize-1));
li = Math.max(0, Math.min(lutSize-1, li));
data[idx]=lut[li*3];
data[idx+1]=lut[li*3+1];
data[idx+2]=lut[li*3+2];
data[idx+3]=255;
}
}
ctx.putImageData(imageData,0,0);
}
async function renderProgressive(myId){
const w=canvas.width, h=canvas.height;
const cssW=canvas.clientWidth, cssH=canvas.clientHeight;
const maxIter=getCurrentMaxIter();
ensureBuffers();
updateLUT();
// progressive steps
const steps = CONFIG.progressive ? CONFIG.progressiveSteps : [1];
for(let s=0;s<steps.length;s++){
if (myId !== renderId) return; // canceled
const stride = steps[s];
// compute iterations at stride
// we compute every pixel but skip? Actually compute all but for coarse we compute sparse and fill.
// For stride>1, compute on grid and nearest-neighbor fill for speed
await computeIterationsChunked(myId, stride, maxIter);
if (myId !== renderId) return;
// for stride>1, fill gaps by nearest sampled value (so preview looks blocky but fast)
if (stride>1){
// fill missing pixels by copying nearest sampled
for(let y=0;y<h;y++){
for(let x=0;x<w;x++){
if (x%stride===0 && y%stride===0) continue;
const sx = Math.floor(x/stride)*stride;
const sy = Math.floor(y/stride)*stride;
const si = sy*w + sx;
const di = y*w + x;
iterBuffer[di]=iterBuffer[si];
}
}
}
colorizeAndBlit(maxIter);
updateHUD();
// small yield to allow UI
await new Promise(r=> requestAnimationFrame(r));
}
// final FPS tick
}
function computeIterationsChunked(myId, stride, maxIter){
return new Promise(resolve=>{
const w=canvas.width, h=canvas.height;
const cssW=canvas.clientWidth, cssH=canvas.clientHeight;
// view.scale is per CSS pixel, so per device pixel = scale / dpr
const scalePerDevice = view.scale / (dpr||1);
const re0 = view.re - (cssW/2)*view.scale;
const im0 = view.im + (cssH/2)*view.scale; // top
let y=0;
function chunk(){
if (myId !== renderId) { resolve(); return; }
const yEnd = Math.min(h, y + CONFIG.chunkRows);
for(let py=y; py<yEnd; py++){
if (stride>1 && py%stride!==0) continue;
const im = im0 - py*scalePerDevice;
for(let px=0; px<w; px++){
if (stride>1 && px%stride!==0) continue;
const re = re0 + px*scalePerDevice;
let mu;
if (isJulia){
// Julia: z0 = pixel, c = juliaC
mu = juliaIter(re, im, juliaC.re, juliaC.im, maxIter);
} else {
mu = mandelbrotIter(re, im, maxIter);
}
iterBuffer[py*w + px]=mu;
}
}
y=yEnd;
if (y < h){
// yield
setTimeout(chunk, 0);
} else {
resolve();
}
}
chunk();
});
}
let renderQueued=false;
function scheduleRender(){
renderId++;
const myId=renderId;
// debounce slightly for wheel
if (renderQueued) return;
renderQueued=true;
requestAnimationFrame(()=>{
renderQueued=false;
renderProgressive(myId);
});
}
// ============================================================
// OVERLAY — selection rectangle
// ============================================================
function drawOverlay(){
const cw = overlay.width, ch = overlay.height;
octx.clearRect(0,0,cw,ch);
if (isDragging && dragStart && dragCurrent && !isPanning){
const x = Math.min(dragStart.x, dragCurrent.x);
const y = Math.min(dragStart.y, dragCurrent.y);
const w = Math.abs(dragCurrent.x - dragStart.x);
const h = Math.abs(dragCurrent.y - dragStart.y);
// dim outside
octx.fillStyle='rgba(0,0,0,0.35)';
octx.fillRect(0,0,cw,ch);
octx.clearRect(x*dpr,y*dpr,w*dpr,h*dpr);
// border inverted
octx.save();
octx.strokeStyle='white';
octx.lineWidth=1.5*dpr;
octx.setLineDash([6*dpr,4*dpr]);
octx.strokeRect(x*dpr+0.5,y*dpr+0.5,w*dpr-1,h*dpr-1);
octx.strokeStyle='black';
octx.setLineDash([6*dpr,4*dpr]);
octx.lineDashOffset=6*dpr;
octx.strokeRect(x*dpr+0.5,y*dpr+0.5,w*dpr-1,h*dpr-1);
octx.restore();
// info
const re0 = pixelToComplex(x,y);
const re1 = pixelToComplex(x+w,y+h);
const selW = Math.abs(re1.re - re0.re);
octx.fillStyle='rgba(255,255,255,0.9)';
octx.font=`${11*dpr}px ui-monospace,monospace`;
octx.fillText(`${selW.toExponential(2)} units`, (x+6)*dpr, (y+16)*dpr);
}
// crosshair for preview mode
if (!isJulia && previewEnabled && !isDragging){
// draw subtle crosshair at last hover? Not needed
}
// center reticle
if (!isDragging){
const cx = overlay.clientWidth/2, cy=overlay.clientHeight/2;
octx.strokeStyle='rgba(255,255,255,0.35)';
octx.lineWidth=1;
octx.beginPath();
octx.moveTo((cx-10)*dpr, cy*dpr); octx.lineTo((cx+10)*dpr, cy*dpr);
octx.moveTo(cx*dpr, (cy-10)*dpr); octx.lineTo(cx*dpr, (cy+10)*dpr);
octx.stroke();
}
}
// ============================================================
// INTERACTION
// ============================================================
function canvasPos(e){
const rect=canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onMouseDown(e){
if (e.button===2 || e.button===1 || spaceHeld || e.shiftKey){
// pan
isPanning=true;
panStart=canvasPos(e);
panViewStart={re:view.re, im:view.im};
e.preventDefault();
return;
}
if (e.button===0){
isDragging=true;
dragStart=canvasPos(e);
dragCurrent={...dragStart};
}
}
function onMouseMove(e){
const pos=canvasPos(e);
// hover Julia preview
if (!isJulia && previewEnabled && !isDragging && !isPanning){
const c = pixelToComplex(pos.x, pos.y);
hoverC.re=c.re; hoverC.im=c.im;
schedulePreview();
// update coord readout
els.coordReadout.innerHTML = `Cursor: <b>${c.re.toFixed(6)}</b> <b>${c.im>0?'+':''}${c.im.toFixed(6)}i</b>`;
}
if (isPanning && panStart){
const dx = pos.x - panStart.x;
const dy = pos.y - panStart.y;
view.re = panViewStart.re - dx*view.scale;
view.im = panViewStart.im + dy*view.scale;
scheduleRender(); updateHUD(); drawOverlay();
resetHudIdle();
} else if (isDragging && dragStart){
dragCurrent=pos;
drawOverlay();
}
}
function onMouseUp(e){
if (isPanning){
isPanning=false;
pushHistory();
scheduleRender();
return;
}
if (isDragging && dragStart && dragCurrent){
const x0=Math.min(dragStart.x,dragCurrent.x), x1=Math.max(dragStart.x,dragCurrent.x);
const y0=Math.min(dragStart.y,dragCurrent.y), y1=Math.max(dragStart.y,dragCurrent.y);
const w=x1-x0, h=y1-y0;
isDragging=false; dragStart=null; dragCurrent=null;
drawOverlay();
if (w<10 || h<10){
// click — if Julia mode off, click to set Julia c? spec says click any point to open Julia
// We'll treat small drag as click: set Julia c and optionally enter Julia mode if shift?
// For now, just set hover and flash
const c = pixelToComplex((x0+x1)/2,(y0+y1)/2);
hoverC.re=c.re; hoverC.im=c.im;
juliaC.re=c.re; juliaC.im=c.im;
els.juliaRe.value=juliaC.re; els.juliaIm.value=juliaC.im;
els.juliaReOut.textContent=juliaC.re.toFixed(3);
els.juliaImOut.textContent=juliaC.im.toFixed(3);
schedulePreview();
// auto-enter Julia if button? we don't auto, but update preview
return;
}
// rectangle zoom — compute new view to exactly match rectangle, preserving aspect
// We must handle aspect ratio: the selected rectangle may not match canvas aspect.
// Spec says must land on exactly selected rectangle — so we adjust view to fit rectangle, expanding to match canvas aspect if needed? But "exactly" suggests we should not shift.
// Approach: compute complex bounds of selection, then set view center to midpoint, and scale to fit width or height whichever is tighter, but ensure selection is fully visible.
// To be exact, we set scale so that selection width maps to canvas width proportionally? Actually selection w/h may differ from canvas aspect, so we need to expand the smaller dimension.
const c0 = pixelToComplex(x0,y0);
const c1 = pixelToComplex(x1,y1);
// c0 is top-left, c1 bottom-right
const selCenterRe = (c0.re + c1.re)/2;
const selCenterIm = (c0.im + c1.im)/2;
const selW = Math.abs(c1.re - c0.re);
const selH = Math.abs(c1.im - c0.im);
const canvasAspect = canvas.clientWidth / canvas.clientHeight;
const selAspect = selW / selH;
let newScale;
if (selAspect > canvasAspect){
// width determines
newScale = selW / canvas.clientWidth;
} else {
newScale = selH / canvas.clientHeight;
}
pushHistory();
view.re = selCenterRe;
view.im = selCenterIm;
view.scale = newScale;
clampView();
scheduleRender(); updateHUD();
}
}
function onWheel(e){
e.preventDefault();
const pos=canvasPos(e);
const cBefore = pixelToComplex(pos.x,pos.y);
const factor = Math.exp(-e.deltaY * 0.0015); // smooth
// clamp factor
const newScale = view.scale / factor;
// check max mag
const init = CONFIG.initialScale||0.0035;
if (init / newScale > CONFIG.maxMagnification && factor>1){
// block
els.depthWarn.style.display='inline';
setTimeout(()=>els.depthWarn.style.display='none',1200);
return;
}
pushHistory();
view.scale = newScale;
const cAfter = pixelToComplex(pos.x,pos.y);
// adjust center so cursor point stays fixed
view.re += cBefore.re - cAfter.re;
view.im += cBefore.im - cAfter.im;
clampView();
scheduleRender(); updateHUD();
resetHudIdle();
}
// ============================================================
// PREVIEW JULIA
// ============================================================
let previewRaf=0, previewDirty=false;
function schedulePreview(){
if (!previewEnabled) return;
previewDirty=true;
if (previewRaf) return;
previewRaf=requestAnimationFrame(()=>{
previewRaf=0;
if (!previewDirty) return;
previewDirty=false;
renderPreview();
});
}
function renderPreview(){
const pc = previewCanvas;
if (!pc) return;
const w=pc.width, h=pc.height;
const ctx2=pctx;
const maxIter= Math.min(512, getCurrentMaxIter());
// Julia c = hoverC
const scale = 3.0 / w; // view width 3 units
const lut = paletteLUT || buildPaletteLUT(lutSize);
const img = ctx2.createImageData(w,h);
const data=img.data;
for(let y=0;y<h;y++){
const im = 1.5 - y*scale;
for(let x=0;x<w;x++){
const re = -1.5 + x*scale;
const mu = juliaIter(re, im, hoverC.re, hoverC.im, maxIter);
const idx=(y*w+x)*4;
if (mu>=maxIter-0.5){ data[idx]=0; data[idx+1]=0; data[idx+2]=0; data[idx+3]=255; }
else {
let t = (mu/maxIter + paletteOffset)%1;
if (paletteKey==='psychedelic') t=(mu*0.07+paletteOffset)%1;
let li=Math.floor(t*(lutSize-1));
data[idx]=lut[li*3]; data[idx+1]=lut[li*3+1]; data[idx+2]=lut[li*3+2]; data[idx+3]=255;
}
}
}
ctx2.putImageData(img,0,0);
els.previewMeta.textContent = `c = ${hoverC.re.toFixed(4)} ${hoverC.im>=0?'+':''}${hoverC.im.toFixed(4)}i`;
document.getElementById('juliaPreview').classList.add('visible');
}
// ============================================================
// HUD / UI
// ============================================================
function updateHUD(){
const mag = magnification();
let magStr;
if (mag < 1000) magStr = `×${mag.toFixed(1)}`;
else if (mag < 1e6) magStr = `×${(mag/1e3).toFixed(1)}k`;
else magStr = `×${mag.toExponential(2)}`;
els.hudZoom.textContent=magStr;
els.hudCenter.textContent=`${view.re.toFixed(6)} ${view.im>=0?'+':''}${view.im.toFixed(6)}i`;
els.hudIters.textContent=getCurrentMaxIter();
els.hudMode.textContent = isJulia ? `Julia c=${juliaC.re.toFixed(2)}${juliaC.im>=0?'+':''}${juliaC.im.toFixed(2)}i` : 'Mandelbrot';
els.iterOut.textContent=getCurrentMaxIter();
if (!autoIter) els.iterOut.textContent=manualIter;
els.coordReadout.innerHTML = `Center: <b>${view.re.toFixed(8)}</b> <b>${view.im>=0?'+':''}${view.im.toFixed(8)}i</b><br>Scale: <b>${view.scale.toExponential(4)}</b> · Mag <b>${mag.toExponential(2)}</b>`;
// depth warn
if (mag > CONFIG.maxMagnification*0.8) els.depthWarn.style.display='inline';
else els.depthWarn.style.display='none';
updateBackButton();
}
function updateBackButton(){
els.btnBack.disabled = historyStack.length===0;
}
function resetHudIdle(){
const hud=document.getElementById('hud');
hud.classList.remove('idle');
clearTimeout(hudIdleTimer);
hudIdleTimer=setTimeout(()=>hud.classList.add('idle'), 2800);
}
// ============================================================
// BOOKMARKS & HASH
// ============================================================
function saveBookmark(name){
const bm = { name: name||`View ${new Date().toLocaleTimeString()}`, view:{re:view.re,im:view.im,scale:view.scale}, palette:paletteKey, paletteOffset, isJulia, juliaC:{...juliaC}, maxIter:getCurrentMaxIter(), autoIter, manualIter };
const arr = JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
arr.push(bm);
localStorage.setItem('fractalBookmarks', JSON.stringify(arr));
renderBookmarks();
}
function renderBookmarks(){
const arr = JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
const cont=els.bookmarks;
cont.innerHTML='';
arr.forEach((bm,i)=>{
const div=document.createElement('div');
div.className='bm';
div.innerHTML=`<div style="flex:1;min-width:0"><div class="bm-name">${bm.name}</div><div class="bm-meta">${bm.view.re.toFixed(4)} ${bm.view.im>=0?'+':''}${bm.view.im.toFixed(4)}i · ×${((CONFIG.initialScale||0.0035)/bm.view.scale).toExponential(1)}</div></div>
<button class="btn small" data-load="${i}">Load</button><button class="btn small" data-del="${i}">✕</button>`;
cont.appendChild(div);
});
cont.querySelectorAll('[data-load]').forEach(b=> b.addEventListener('click',()=>{
const idx=parseInt(b.getAttribute('data-load'));
const arr2=JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
const bm=arr2[idx];
if (!bm) return;
pushHistory();
view.re=bm.view.re; view.im=bm.view.im; view.scale=bm.view.scale;
paletteKey=bm.palette||paletteKey; paletteOffset=bm.paletteOffset||0;
isJulia=!!bm.isJulia; if (bm.juliaC) juliaC={...bm.juliaC};
autoIter=bm.autoIter!==undefined?bm.autoIter:autoIter;
manualIter=bm.manualIter||manualIter;
// update UI
els.cycleSlider.value=Math.round(paletteOffset*100);
els.cycleOut.textContent=Math.round(paletteOffset*100)+'%';
els.autoIterToggle.checked=autoIter;
els.iterSlider.value=manualIter;
els.juliaRe.value=juliaC.re; els.juliaIm.value=juliaC.im;
els.juliaReOut.textContent=juliaC.re.toFixed(3); els.juliaImOut.textContent=juliaC.im.toFixed(3);
updatePaletteUI();
updateJuliaToggle();
scheduleRender(); updateHUD(); updateHash();
}));
cont.querySelectorAll('[data-del]').forEach(b=> b.addEventListener('click',()=>{
const idx=parseInt(b.getAttribute('data-del'));
const arr2=JSON.parse(localStorage.getItem('fractalBookmarks')||'[]');
arr2.splice(idx,1);
localStorage.setItem('fractalBookmarks', JSON.stringify(arr2));
renderBookmarks();
}));
}
function updateHash(){
const h = `#re=${view.re}&im=${view.im}&scale=${view.scale}&pal=${paletteKey}&off=${paletteOffset.toFixed(3)}&julia=${isJulia?1:0}&jr=${juliaC.re}&ji=${juliaC.im}`;
history.replaceState(null,'',h);
}
function loadHash(){
if (!location.hash) return false;
try{
const p=new URLSearchParams(location.hash.slice(1));
if (p.has('re')) view.re=parseFloat(p.get('re'));
if (p.has('im')) view.im=parseFloat(p.get('im'));
if (p.has('scale')) view.scale=parseFloat(p.get('scale'));
if (p.has('pal') && CONFIG.palettes[p.get('pal')]) paletteKey=p.get('pal');
if (p.has('off')) paletteOffset=parseFloat(p.get('off'));
if (p.has('julia')) isJulia=p.get('julia')==='1';
if (p.has('jr')) juliaC.re=parseFloat(p.get('jr'));
if (p.has('ji')) juliaC.im=parseFloat(p.get('ji'));
return true;
}catch(e){ return false; }
}
// ============================================================
// PALETTE UI
// ============================================================
function updatePaletteUI(){
document.querySelectorAll('.chip').forEach(c=>{
c.classList.toggle('active', c.dataset.pal===paletteKey);
});
els.paletteName.textContent = CONFIG.palettes[paletteKey]?.name || paletteKey;
}
function buildPaletteChips(){
const cont=els.paletteChips;
cont.innerHTML='';
for(const [key,pal] of Object.entries(CONFIG.palettes)){
const chip=document.createElement('button');
chip.className='chip'; chip.dataset.pal=key;
// swatch gradient
const grad = pal.stops.map(s=> `rgb(${s.c[0]},${s.c[1]},${s.c[2]}) ${s.t*100}%`).join(',');
chip.innerHTML=`<span class="swatch" style="background:linear-gradient(90deg,${grad})"></span><span>${pal.name}</span>`;
chip.addEventListener('click',()=>{
paletteKey=key; updatePaletteUI(); updateLUT(); scheduleRender(); updateHash();
});
cont.appendChild(chip);
}
updatePaletteUI();
}
function updateJuliaToggle(){
els.btnJuliaToggle.textContent = isJulia ? 'Julia: On' : 'Julia: Off';
els.btnJuliaToggle.classList.toggle('primary', isJulia);
}
// ============================================================
// INIT & RESIZE
// ============================================================
function resize(){
dpr = Math.min(2, window.devicePixelRatio||1);
const rect = canvas.getBoundingClientRect();
const w = Math.round(rect.width * dpr);
const h = Math.round(rect.height * dpr);
if (w===0||h===0) return;
canvas.width=w; canvas.height=h;
overlay.width=w; overlay.height=h;
W=w; H=h;
// reset buffers
iterBuffer=null; imageData=null;
if (!CONFIG.initialScale){
CONFIG.initialScale = 3.5 / rect.width; // so initial view width ~3.5 units
view.scale = CONFIG.initialScale;
}
scheduleRender();
drawOverlay();
}
function init(){
canvas=document.getElementById('mainCanvas');
ctx=canvas.getContext('2d', {alpha:false});
overlay=document.getElementById('overlayCanvas');
octx=overlay.getContext('2d');
previewCanvas=document.getElementById('previewCanvas');
pctx=previewCanvas.getContext('2d');
// els
els.hudZoom=document.getElementById('hudZoom');
els.hudCenter=document.getElementById('hudCenter');
els.hudIters=document.getElementById('hudIters');
els.hudMode=document.getElementById('hudMode');
els.btnBack=document.getElementById('btnBack');
els.btnReset=document.getElementById('btnReset');
els.btnJuliaToggle=document.getElementById('btnJuliaToggle');
els.paletteChips=document.getElementById('paletteChips');
els.paletteName=document.getElementById('paletteName');
els.cycleSlider=document.getElementById('cycleSlider');
els.cycleOut=document.getElementById('cycleOut');
els.smoothToggle=document.getElementById('smoothToggle');
els.previewToggle=document.getElementById('previewToggle');
els.iterSlider=document.getElementById('iterSlider');
els.iterOut=document.getElementById('iterOut');
els.autoIterToggle=document.getElementById('autoIterToggle');
els.juliaRe=document.getElementById('juliaRe');
els.juliaIm=document.getElementById('juliaIm');
els.juliaReOut=document.getElementById('juliaReOut');
els.juliaImOut=document.getElementById('juliaImOut');
els.bookmarks=document.getElementById('bookmarks');
els.coordReadout=document.getElementById('coordReadout');
els.depthWarn=document.getElementById('depthWarn');
els.previewMeta=document.getElementById('previewMeta');
// build UI
buildPaletteChips();
renderBookmarks();
// load hash
loadHash();
// resize
resize();
window.addEventListener('resize', resize);
// events
canvas.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('wheel', onWheel, {passive:false});
canvas.addEventListener('contextmenu', e=> e.preventDefault());
overlay.addEventListener('contextmenu', e=> e.preventDefault());
// keyboard
window.addEventListener('keydown', e=>{
if (e.code==='Space') spaceHeld=true;
if (e.key==='j' || e.key==='J'){ isJulia=!isJulia; updateJuliaToggle(); pushHistory(); scheduleRender(); updateHUD(); updateHash(); }
if (e.key==='h' || e.key==='H'){ showUI=!showUI; document.getElementById('panel').style.display=showUI?'flex':'none'; document.getElementById('juliaPreview').style.display= showUI && previewEnabled ? 'block':'none'; document.getElementById('hint').style.display= showUI?'flex':'none'; }
if (e.key==='Escape'){ isDragging=false; dragStart=null; dragCurrent=null; drawOverlay(); }
// pan arrows
const panStep = view.scale * 40;
if (e.key==='ArrowLeft'){ view.re -= panStep; scheduleRender(); updateHUD(); }
if (e.key==='ArrowRight'){ view.re += panStep; scheduleRender(); updateHUD(); }
if (e.key==='ArrowUp'){ view.im += panStep; scheduleRender(); updateHUD(); }
if (e.key==='ArrowDown'){ view.im -= panStep; scheduleRender(); updateHUD(); }
if (e.key==='+'||e.key==='='){ view.scale *= 0.85; scheduleRender(); updateHUD(); }
if (e.key==='-'||e.key==='_'){ view.scale *= 1.15; scheduleRender(); updateHUD(); }
});
window.addEventListener('keyup', e=>{ if (e.code==='Space') spaceHeld=false; if (e.code==='Space') isPanning=false; });
// buttons
els.btnBack.addEventListener('click', popHistory);
els.btnReset.addEventListener('click', resetView);
els.btnJuliaToggle.addEventListener('click', ()=>{ isJulia=!isJulia; updateJuliaToggle(); scheduleRender(); updateHUD(); updateHash(); });
els.cycleSlider.addEventListener('input', e=>{
paletteOffset = parseInt(e.target.value)/100;
els.cycleOut.textContent=e.target.value+'%';
updateLUT(); colorizeAndBlit(getCurrentMaxIter()); updateHash();
});
els.smoothToggle.addEventListener('change', e=>{ smoothColor=e.target.checked; scheduleRender(); });
els.previewToggle.addEventListener('change', e=>{
previewEnabled=e.target.checked;
document.getElementById('juliaPreview').classList.toggle('visible', previewEnabled);
if (!previewEnabled) document.getElementById('juliaPreview').style.display='none';
else document.getElementById('juliaPreview').style.display='block';
});
els.autoIterToggle.addEventListener('change', e=>{ autoIter=e.target.checked; els.iterSlider.disabled=autoIter; scheduleRender(); updateHUD(); });
els.iterSlider.addEventListener('input', e=>{
manualIter=parseInt(e.target.value);
els.iterOut.textContent=manualIter;
if (!autoIter) scheduleRender();
});
els.juliaRe.addEventListener('input', e=>{
juliaC.re=parseFloat(e.target.value); els.juliaReOut.textContent=juliaC.re.toFixed(3);
if (isJulia) scheduleRender(); else schedulePreview();
updateHash();
});
els.juliaIm.addEventListener('input', e=>{
juliaC.im=parseFloat(e.target.value); els.juliaImOut.textContent=juliaC.im.toFixed(3);
if (isJulia) scheduleRender(); else schedulePreview();
updateHash();
});
document.getElementById('btnSeahorse').addEventListener('click', ()=>{
pushHistory(); view.re=CONFIG.presets.seahorse.re; view.im=CONFIG.presets.seahorse.im; view.scale=CONFIG.presets.seahorse.scale; scheduleRender(); updateHUD();
});
document.getElementById('btnElephant').addEventListener('click', ()=>{
pushHistory(); view.re=CONFIG.presets.elephant.re; view.im=CONFIG.presets.elephant.im; view.scale=CONFIG.presets.elephant.scale; scheduleRender(); updateHUD();
});
document.getElementById('btnEnterJulia').addEventListener('click', ()=>{
juliaC.re=hoverC.re; juliaC.im=hoverC.im;
els.juliaRe.value=juliaC.re; els.juliaIm.value=juliaC.im;
els.juliaReOut.textContent=juliaC.re.toFixed(3); els.juliaImOut.textContent=juliaC.im.toFixed(3);
isJulia=true; updateJuliaToggle(); scheduleRender(); updateHUD(); updateHash();
});
document.getElementById('btnSaveBm').addEventListener('click', ()=>{
const name=prompt('Bookmark name:', `View ${new Date().toLocaleTimeString()}`);
if (name!==null) saveBookmark(name||undefined);
});
document.getElementById('btnClearBm').addEventListener('click', ()=>{
if (confirm('Clear all bookmarks?')){ localStorage.removeItem('fractalBookmarks'); renderBookmarks(); }
});
document.getElementById('btnShare').addEventListener('click', ()=>{
updateHash();
const url=location.href;
navigator.clipboard.writeText(url).then(()=>{ const b=document.getElementById('btnShare'); const t=b.textContent; b.textContent='Copied!'; setTimeout(()=>b.textContent=t,1200); });
});
// initial — render synchronously so first paint is immediate
updateLUT();
updateJuliaToggle();
updateHUD();
renderId++;
renderProgressive(renderId);
schedulePreview();
resetHudIdle();
// FPS loop
function fpsLoop(){
frames++;
const now=performance.now();
if (now - lastFpsTime > 500){
fpsVal = Math.round(frames*1000/(now-lastFpsTime));
document.getElementById('fps').textContent = fpsVal + ' fps';
frames=0; lastFpsTime=now;
}
requestAnimationFrame(fpsLoop);
}
fpsLoop();
// overlay draw loop
setInterval(drawOverlay, 50);
// expose debug interface
window.fractal = {
iterationsAt,
juliaIterationsAt,
setView,
getView,
get view(){ return {...view}; },
get juliaC(){ return {...juliaC}; },
get palette(){ return paletteKey; },
set palette(v){ if (CONFIG.palettes[v]){ paletteKey=v; updatePaletteUI(); updateLUT(); scheduleRender(); }},
render: scheduleRender,
CONFIG
};
}
document.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"2d953891-5a8b-4293-a360-0d7f44b06a7a","tokensIn":2012063,"tokensOut":31974,"tokensTotal":2044037,"cost":0.034194118,"turns":40,"toolCalls":39,"failedToolCalls":0,"timestamp":"2026-08-23T00:43:05.202Z"} -->