Fractal Explorer
GLM 5.3 Flash · 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 32,114 bytes · SHA-256 c70596e14375
<!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{
--glass: rgba(12,14,22,.62);
--line: rgba(255,255,255,.14);
--txt: #e8ecf4;
--dim: #9aa3b5;
--accent: #6ea8ff;
}
*{ box-sizing:border-box; margin:0; padding:0; }
html,body{ width:100%; height:100%; overflow:hidden; background:#000; color:var(--txt);
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
canvas#view, canvas#overlay{ position:fixed; inset:0; width:100vw; height:100vh; display:block; }
canvas#overlay{ pointer-events:none; }
.panel{
position:fixed; background:var(--glass); border:1px solid var(--line); border-radius:12px;
backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
padding:10px 12px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
transition: opacity .45s ease; user-select:none;
}
/* ---- info HUD (top-left) ---- */
#hud{ top:14px; left:14px; min-width:230px; }
#hud.hide{ opacity:0; pointer-events:none; }
#hud .row{ display:flex; justify-content:space-between; gap:14px; }
#hud .k{ color:var(--dim); }
#hud .v{ color:var(--txt); text-align:right; white-space:nowrap; }
#hud .setTitle{ font-weight:700; letter-spacing:.08em; color:var(--accent); margin-bottom:4px; }
/* ---- controls (bottom) ---- */
#controls{ left:14px; bottom:14px; right:14px; display:flex; gap:10px; align-items:stretch; flex-wrap:wrap; }
#controls .grp{ display:flex; gap:8px; align-items:center; background:var(--glass);
border:1px solid var(--line); border-radius:12px; padding:8px 10px;
backdrop-filter: blur(14px); box-shadow:0 8px 30px rgba(0,0,0,.45); }
.chip{ width:34px; height:22px; border-radius:6px; border:2px solid rgba(255,255,255,.25);
cursor:pointer; padding:0; }
.chip.sel{ border-color:#fff; box-shadow:0 0 0 2px rgba(110,168,255,.7); }
button{ background:rgba(255,255,255,.08); color:var(--txt); border:1px solid var(--line);
border-radius:8px; padding:5px 10px; cursor:pointer; font:inherit; }
button:hover{ background:rgba(255,255,255,.16); }
label{ color:var(--dim); display:flex; align-items:center; gap:6px; }
input[type=range]{ width:110px; accent-color:var(--accent); }
input[type=checkbox]{ accent-color:var(--accent); }
/* ---- julia preview (top-right) ---- */
#previewWrap{ top:14px; right:14px; padding:8px; display:none; }
#previewWrap.on{ display:block; }
#previewWrap .cap{ color:var(--dim); font-size:11px; text-align:center; margin-top:5px; }
#preview{ display:block; border-radius:6px; width:180px; height:180px; }
/* ---- bookmarks ---- */
#bookmarks{ top:14px; left:50%; transform:translateX(-50%); display:none; max-width:420px; }
#bookmarks.on{ display:block; }
#bookmarks h3{ font-size:12px; color:var(--accent); margin-bottom:6px; letter-spacing:.08em; }
#bmList{ display:flex; flex-direction:column; gap:4px; max-height:40vh; overflow:auto; }
.bm{ display:flex; gap:8px; align-items:center; }
.bm button.go{ flex:1; text-align:left; }
.bm button.del{ padding:2px 8px; color:#ff8f8f; }
.empty{ color:var(--dim); }
/* ---- help ---- */
#help{ top:50%; left:50%; transform:translate(-50%,-50%); display:none; max-width:520px; z-index:10; }
#help.on{ display:block; }
#help h3{ color:var(--accent); margin-bottom:8px; letter-spacing:.08em; }
#help td{ padding:2px 10px 2px 0; }
#help td:first-child{ color:var(--accent); white-space:nowrap; }
/* ---- toast ---- */
#toast{ position:fixed; bottom:78px; left:50%; transform:translateX(-50%);
background:var(--glass); border:1px solid var(--line); border-radius:10px; padding:8px 16px;
opacity:0; transition:opacity .3s; pointer-events:none; backdrop-filter:blur(14px); }
#toast.on{ opacity:1; }
#warn{ position:fixed; top:14px; left:50%; transform:translateX(-50%); color:#ffd27d;
background:var(--glass); border:1px solid rgba(255,200,100,.4); border-radius:10px;
padding:6px 14px; display:none; backdrop-filter:blur(14px); }
#warn.on{ display:block; }
</style>
</head>
<body>
<canvas id="view"></canvas>
<canvas id="overlay"></canvas>
<div id="hud" class="panel">
<div class="setTitle" id="hudSet">MANDELBROT</div>
<div class="row"><span class="k">zoom</span><span class="v" id="hudMag">×1.0e0</span></div>
<div class="row"><span class="k">center</span><span class="v" id="hudC">0, 0</span></div>
<div class="row"><span class="k">iterations</span><span class="v" id="hudIter">80</span></div>
<div class="row"><span class="k">fps</span><span class="v" id="hudFps">–</span></div>
</div>
<div id="previewWrap" class="panel">
<canvas id="preview" width="180" height="180"></canvas>
<div class="cap">Julia preview — click to open</div>
</div>
<div id="bookmarks" class="panel">
<h3>BOOKMARKS</h3>
<div id="bmList"></div>
</div>
<div id="help" class="panel">
<h3>CONTROLS</h3>
<table>
<tr><td>drag</td><td>rectangle zoom (selection box)</td></tr>
<tr><td>right / middle drag</td><td>pan</td></tr>
<tr><td>wheel</td><td>zoom toward cursor</td></tr>
<tr><td>click a point</td><td>open Julia set for that c</td></tr>
<tr><td>J</td><td>toggle Julia / Mandelbrot</td></tr>
<tr><td>arrows</td><td>pan · <b>+ / −</b> zoom</td></tr>
<tr><td>B / R</td><td>back / reset view</td></tr>
<tr><td>P</td><td>toggle live Julia preview</td></tr>
<tr><td>M</td><td>bookmarks · <b>S</b> save bookmark</td></tr>
<tr><td>H</td><td>this help</td></tr>
</table>
<div style="margin-top:8px;color:var(--dim)">Double-precision limit: zoom is capped at ×1e13.</div>
</div>
<div id="controls">
<div class="grp" id="palGrp"><span style="color:var(--dim)">palette</span></div>
<div class="grp">
<label>cycle <input type="range" id="cycSlider" min="0" max="1000" value="0"></label>
<label><input type="checkbox" id="cycAnim"> animate</label>
</div>
<div class="grp">
<label><input type="checkbox" id="autoIter" checked> auto iters</label>
<input type="range" id="iterSlider" min="50" max="4000" value="600">
<span id="iterLabel" style="color:var(--dim)">600</span>
</div>
<div class="grp">
<button id="btnBack" title="Back (B)">◀ back</button>
<button id="btnReset" title="Reset (R)">reset</button>
<button id="btnSave" title="Save bookmark (S)">★ save</button>
<button id="btnBm" title="Bookmarks (M)">bookmarks</button>
<button id="btnHelp" title="Help (H)">?</button>
</div>
</div>
<div id="toast"></div>
<div id="warn">⚠ precision limit — zoom capped at ×1e13</div>
<script>
'use strict';
/* =====================================================================
1. TUNABLE CONSTANTS
===================================================================== */
const CFG = {
BASE_WIDTH: 3.5, // complex-plane width at magnification ×1
MAX_MAG: 1e13, // double-precision ceiling; zoom blocked past this
BAILOUT: 4, // escape radius; smooth formula n+1-log2(log|z|/log R) is
// continuous for any R>2; small R => c=2 escapes in 2 steps
ITER_BASE: 80, // auto iteration floor
ITER_SCALE: 26, // extra iterations per decade of magnification^1.25
ITER_MIN: 32, ITER_MAX: 12000,
ZOOM_SPEED: 1.0, // wheel zoom rate multiplier
WHEEL_RATE: 0.0016, // wheel delta -> log zoom factor
FRAME_BUDGET: 10, // ms of compute per animation frame (responsiveness)
REFINE_PASSES: [4, 2, 1], // progressive pass downsample factors
INTERACT_PASSES: [6, 3], // cheaper passes while dragging/zooming
LUT_N: 2048, // color lookup table size
COLOR_SCALE: 0.16, // palette cycles per sqrt(iteration)
PREVIEW_RES: 96, // julia preview internal resolution
PREVIEW_ITER: 150,
DPR_CAP: 1.5,
HUD_IDLE_MS: 3000,
CYCLE_RATE: 0.0022, // palette drift speed when "animate" is on
};
/* =====================================================================
2. PALETTES (stops: [pos, '#hex'], cyclic palettes repeat first stop)
===================================================================== */
const PALETTES = [
{ name: 'Fire', stops: [[0,'#02010a'],[.18,'#2a0a54'],[.38,'#7c2382'],[.55,'#d9455f'],[.72,'#f98c0a'],[.88,'#f9e8a0'],[1,'#fffdea']] },
{ name: 'Ocean', stops: [[0,'#01040f'],[.28,'#06316e'],[.52,'#1e90d8'],[.75,'#7fe3d6'],[1,'#f2fffb']] },
{ name: 'Mono', stops: [[0,'#000000'],[.45,'#6f6f6f'],[1,'#ffffff']] },
{ name: 'Psyche',stops: [[0,'#ff004d'],[.17,'#ff9a00'],[.33,'#eaff00'],[.5,'#00ff6e'],[.67,'#00c8ff'],[.83,'#8a00ff'],[1,'#ff004d']] },
{ name: 'Ultra', stops: [[0,'#000764'],[.16,'#206bcb'],[.42,'#edffff'],[.6425,'#ffaa00'],[.8575,'#310230'],[1,'#000764']] },
];
/* =====================================================================
3. STATE
===================================================================== */
const state = {
set: 'mandel', // 'mandel' | 'julia'
cx: -0.75, cy: 0, // view center (complex)
viewW: CFG.BASE_WIDTH, // complex-plane width of the viewport
jcRe: -0.8, jcIm: 0, // julia c value
palette: 0,
cycleOffset: 0,
cycleAnim: false,
autoIter: true,
iterOverride: 600,
previewOn: true,
interacting: false,
};
let maxIter = CFG.ITER_BASE;
const history = []; // zoom/pan history for Back
let viewVersion = 0; // bumped on every view change (cancels renders)
const canvas = document.getElementById('view');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const octx = overlay.getContext('2d');
let cssW = 0, cssH = 0, dpr = 1;
const magnification = () => CFG.BASE_WIDTH / state.viewW;
/* =====================================================================
4. MATH CORE — escape-time iteration (allocation-free)
Results via module-level "registers"; no objects/arrays per pixel.
===================================================================== */
const LOG_R = Math.log(CFG.BAILOUT);
const R2 = CFG.BAILOUT * CFG.BAILOUT;
let r_escaped = false, r_n = 0, r_mu = 0;
function iterMandel(cr, ci, maxIt) {
// interior pre-checks: main cardioid + period-2 bulb
const q = (cr - 0.25) * (cr - 0.25) + ci * ci;
if (q * (q + (cr - 0.25)) <= 0.25 * ci * ci) { r_escaped = false; r_n = maxIt; r_mu = -1; return; }
const p = cr + 1;
if (p * p + ci * ci <= 0.0625) { r_escaped = false; r_n = maxIt; r_mu = -1; return; }
let zr = 0, zi = 0, n = 0;
while (n < maxIt) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > R2) {
r_escaped = true; r_n = n;
r_mu = n + 1 - Math.log2(Math.log(Math.sqrt(zr2 + zi2)) / LOG_R);
return;
}
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
n++;
}
r_escaped = false; r_n = maxIt; r_mu = -1;
}
function iterJulia(zr, zi, maxIt, cr, ci) {
let n = 0;
while (n < maxIt) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > R2) {
r_escaped = true; r_n = n;
r_mu = n + 1 - Math.log2(Math.log(Math.sqrt(zr2 + zi2)) / LOG_R);
return;
}
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
n++;
}
r_escaped = false; r_n = maxIt; r_mu = -1;
}
function iterate(re, im, maxIt, set) {
if ((set || state.set) === 'mandel') iterMandel(re, im, maxIt);
else iterJulia(re, im, maxIt, state.jcRe, state.jcIm);
}
/* =====================================================================
5. COLOR — LUT build + smooth colorize from a smooth-iteration buffer
===================================================================== */
const LUT = new Uint8Array(CFG.LUT_N * 3);
function hex2rgb(h) {
return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
}
function buildLUT() {
const stops = PALETTES[state.palette].stops.map(([p, h]) => [p, hex2rgb(h)]);
const cyc = stops[stops.length - 1][0] >= 0.999; // cyclic palette
for (let i = 0; i < CFG.LUT_N; i++) {
let t = i / CFG.LUT_N;
if (!cyc) t = Math.min(t, 0.9999);
let a = stops[0], b = stops[stops.length - 1];
for (let s = 0; s < stops.length - 1; s++) {
if (t >= stops[s][0] && t <= stops[s + 1][0]) { a = stops[s]; b = stops[s + 1]; break; }
}
let u = (t - a[0]) / Math.max(1e-9, b[0] - a[0]);
u = u * u * (3 - 2 * u); // smoothstep between stops
for (let c = 0; c < 3; c++)
LUT[i * 3 + c] = Math.round(a[1][c] + (b[1][c] - a[1][c]) * u);
}
}
function colorize(mu, img, count) {
const d = img.data, N = CFG.LUT_N;
const off = state.cycleOffset;
for (let i = 0, p = 0; i < count; i++, p += 4) {
const m = mu[i];
if (m < 0) { d[p] = 0; d[p + 1] = 0; d[p + 2] = 0; d[p + 3] = 255; }
else {
let t = Math.sqrt(m) * CFG.COLOR_SCALE + off;
t -= Math.floor(t);
const ci = ((t * N) | 0) * 3;
d[p] = LUT[ci]; d[p + 1] = LUT[ci + 1]; d[p + 2] = LUT[ci + 2]; d[p + 3] = 255;
}
}
}
/* =====================================================================
6. RENDERER — progressive, time-sliced, cancellable
===================================================================== */
const off = document.createElement('canvas');
const offCtx = off.getContext('2d');
let job = null;
let fullMu = null; // last full-resolution smooth-iteration buffer (for recolor-only ops)
function computeMaxIter() {
if (!state.autoIter) { maxIter = state.iterOverride; return; }
const m = Math.max(1, magnification());
maxIter = Math.round(CFG.ITER_BASE + CFG.ITER_SCALE * Math.pow(Math.log10(m) + 1, 1.25));
maxIter = Math.min(CFG.ITER_MAX, Math.max(CFG.ITER_MIN, maxIter));
}
function newJob(passes) {
viewVersion++;
job = { token: viewVersion, passes, idx: 0, row: 0, scale: 0,
W: canvas.width, H: canvas.height, bw: 0, bh: 0, mu: null, img: null };
}
function tickJob() {
if (!job) return;
const t0 = performance.now();
const s = job.passes[job.idx];
const w = Math.max(1, Math.ceil(job.W / s)), h = Math.max(1, Math.ceil(job.H / s));
if (job.scale !== s || job.bw !== w || job.bh !== h) {
job.scale = s; job.bw = w; job.bh = h;
job.mu = new Float32Array(w * h);
job.img = new ImageData(w, h);
job.row = 0;
}
const ppu = state.viewW / job.W, halfW = job.W / 2, halfH = job.H / 2;
const mu = job.mu, mi = maxIter, mandel = state.set === 'mandel';
const jcr = state.jcRe, jci = state.jcIm;
while (job.row < h) {
const y = job.row;
const im = state.cy - (y * s + s / 2 - halfH) * ppu;
let base = y * w;
if (mandel) {
for (let x = 0; x < w; x++) {
iterMandel(state.cx + (x * s + s / 2 - halfW) * ppu, im, mi);
mu[base + x] = r_mu;
}
} else {
for (let x = 0; x < w; x++) {
iterJulia(state.cx + (x * s + s / 2 - halfW) * ppu, im, mi, jcr, jci);
mu[base + x] = r_mu;
}
}
job.row++;
if (performance.now() - t0 >= CFG.FRAME_BUDGET) return; // yield to keep UI responsive
}
// pass complete: colorize + blit
colorize(job.mu, job.img, w * h);
if (s === 1) {
ctx.putImageData(job.img, 0, 0);
fullMu = { mu: job.mu, w, h, img: job.img };
job = null;
} else {
off.width = w; off.height = h;
offCtx.putImageData(job.img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.clearRect(0, 0, job.W, job.H);
ctx.drawImage(off, 0, 0, job.W, job.H);
job.idx++; job.row = 0; job.scale = 0;
if (job.idx >= job.passes.length) job = null;
}
}
let renderQueued = false;
function requestRender() {
if (renderQueued) return;
renderQueued = true;
requestAnimationFrame(() => {
renderQueued = false;
computeMaxIter();
newJob(state.interacting ? CFG.INTERACT_PASSES : CFG.REFINE_PASSES);
updateHash(); updateHUD();
});
}
function recolorOnly() { // palette / cycle-offset change: no iteration recompute
if (!fullMu || fullMu.w !== canvas.width || fullMu.h !== canvas.height) { requestRender(); return; }
colorize(fullMu.mu, fullMu.img, fullMu.w * fullMu.h);
ctx.putImageData(fullMu.img, 0, 0);
}
/* ---- main loop ---- */
let frames = 0, fpsT = performance.now(), lastActivity = performance.now();
function frame() {
if (job) tickJob();
if (pvDirty) { pvDirty = false; renderPreview(); }
if (state.cycleAnim) {
state.cycleOffset = (state.cycleOffset + CFG.CYCLE_RATE) % 1;
recolorOnly();
if (!cycSlider.matches(':active')) cycSlider.value = Math.round(state.cycleOffset * 1000);
}
frames++;
const now = performance.now();
if (now - fpsT > 500) {
hudFps.textContent = Math.round(frames * 1000 / (now - fpsT));
frames = 0; fpsT = now;
}
hud.classList.toggle('hide', now - lastActivity > CFG.HUD_IDLE_MS);
requestAnimationFrame(frame);
}
function resize() {
cssW = innerWidth; cssH = innerHeight;
dpr = Math.min(devicePixelRatio || 1, CFG.DPR_CAP);
canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr);
overlay.width = canvas.width; overlay.height = canvas.height;
octx.setTransform(dpr, 0, 0, dpr, 0, 0);
fullMu = null;
requestRender();
}
addEventListener('resize', resize);
/* =====================================================================
7. VIEW OPERATIONS — zoom, pan, rect zoom, history
===================================================================== */
const px2re = x => state.cx + (x - cssW / 2) * (state.viewW / cssW);
const px2im = y => state.cy - (y - cssH / 2) * (state.viewW / cssW);
function pushHistory() {
history.push({ set: state.set, cx: state.cx, cy: state.cy, viewW: state.viewW,
jcRe: state.jcRe, jcIm: state.jcIm });
if (history.length > 150) history.shift();
}
function clampMag() {
if (magnification() > CFG.MAX_MAG) {
state.viewW = CFG.BASE_WIDTH / CFG.MAX_MAG;
warnEl.classList.add('on');
clearTimeout(clampMag._t);
clampMag._t = setTimeout(() => warnEl.classList.remove('on'), 2500);
return true;
}
return false;
}
function setView(v) {
if ('scale' in v) state.viewW = v.scale; // scale = complex width of viewport
if ('mag' in v) state.viewW = CFG.BASE_WIDTH / v.mag;
if ('re' in v) state.cx = v.re;
if ('im' in v) state.cy = v.im;
clampMag(); requestRender();
}
function zoomAt(x, y, factor) {
pushHistory();
const re = px2re(x), im = px2im(y), k = 1 / factor;
state.cx = re + (state.cx - re) * k;
state.cy = im + (state.cy - im) * k;
state.viewW *= k;
clampMag(); requestRender();
}
function panBy(dxPx, dyPx) {
const ppu = state.viewW / cssW;
state.cx -= dxPx * ppu; state.cy += dyPx * ppu;
requestRender();
}
function rectZoom(x0, y0, x1, y1) {
const re0 = px2re(x0), im0 = px2im(y0), re1 = px2re(x1), im1 = px2im(y1);
const dRe = Math.abs(re1 - re0), dIm = Math.abs(im1 - im0);
if (dRe < 1e-15 && dIm < 1e-15) return;
const newW = Math.max(dRe, dIm * (cssW / cssH)); // fit rect, keep canvas aspect
pushHistory();
state.cx = (re0 + re1) / 2;
state.cy = (im0 + im1) / 2;
state.viewW = newW;
clampMag(); requestRender();
}
function back() {
const h = history.pop();
if (!h) { toast('No history'); return; }
Object.assign(state, h); requestRender();
}
function resetView() {
pushHistory();
Object.assign(state, { set: 'mandel', cx: -0.75, cy: 0, viewW: CFG.BASE_WIDTH });
requestRender();
}
function openJulia(re, im) {
pushHistory();
Object.assign(state, { set: 'julia', jcRe: re, jcIm: im, cx: 0, cy: 0, viewW: 3.5 });
requestRender();
}
function toggleJulia() {
if (state.set === 'julia') back(); // pop back to the Mandelbrot view
else openJulia(state.cx, state.cy);
}
/* =====================================================================
8. INPUT — pointer (rect zoom / pan / click), wheel, keyboard
===================================================================== */
let drag = null, mouse = { x: -1, y: -1, over: false };
let interactTimer = null;
function pokeInteraction() {
state.interacting = true;
clearTimeout(interactTimer);
interactTimer = setTimeout(() => { state.interacting = false; requestRender(); }, 180);
}
canvas.addEventListener('contextmenu', e => e.preventDefault());
canvas.addEventListener('pointerdown', e => {
try { canvas.setPointerCapture(e.pointerId); } catch (err) {}
lastActivity = performance.now();
drag = { mode: (e.button === 0 && !e.altKey) ? 'rect' : 'pan',
x0: e.clientX, y0: e.clientY, moved: false };
});
canvas.addEventListener('pointermove', e => {
lastActivity = performance.now();
mouse.x = e.clientX; mouse.y = e.clientY; mouse.over = true;
if (!drag) { pvDirty = state.previewOn && state.set === 'mandel'; drawOverlay(); return; }
const dx = e.clientX - drag.x0, dy = e.clientY - drag.y0;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
if (drag.mode === 'pan') {
panBy(dx, dy); pokeInteraction();
drag.x0 = e.clientX; drag.y0 = e.clientY;
} else {
drag.x1 = e.clientX; drag.y1 = e.clientY;
}
drawOverlay();
});
canvas.addEventListener('pointerup', e => {
if (!drag) return;
const d = drag; drag = null; drawOverlay();
if (d.mode === 'rect' && !d.moved) {
if (state.set === 'mandel') openJulia(px2re(e.clientX), px2im(e.clientY));
return;
}
if (d.mode === 'rect') rectZoom(d.x0, d.y0, e.clientX, e.clientY);
});
canvas.addEventListener('pointerleave', () => { mouse.over = false; pvDirty = true; drawOverlay(); });
canvas.addEventListener('wheel', e => {
e.preventDefault();
lastActivity = performance.now();
let f = Math.exp(-e.deltaY * CFG.WHEEL_RATE * CFG.ZOOM_SPEED);
if (magnification() * f > CFG.MAX_MAG) { f = CFG.MAX_MAG / magnification(); clampMag(); }
zoomAt(e.clientX, e.clientY, f);
pokeInteraction();
}, { passive: false });
addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' && e.target.type === 'text') return;
lastActivity = performance.now();
const step = state.viewW * 0.12;
switch (e.key) {
case 'ArrowLeft': pushHistory(); state.cx -= step; requestRender(); break;
case 'ArrowRight': pushHistory(); state.cx += step; requestRender(); break;
case 'ArrowUp': pushHistory(); state.cy += step; requestRender(); break;
case 'ArrowDown': pushHistory(); state.cy -= step; requestRender(); break;
case '+': case '=': zoomAt(cssW / 2, cssH / 2, 1.5); break;
case '-': case '_': zoomAt(cssW / 2, cssH / 2, 1 / 1.5); break;
case 'j': case 'J': toggleJulia(); break;
case 'b': case 'B': back(); break;
case 'r': case 'R': resetView(); break;
case 'p': case 'P': state.previewOn = !state.previewOn; previewWrap.classList.toggle('on', state.previewOn && state.set === 'mandel'); pvDirty = true; break;
case 'm': case 'M': bookmarksEl.classList.toggle('on'); renderBmList(); break;
case 's': case 'S': saveBookmark(); break;
case 'h': case 'H': case '?': helpEl.classList.toggle('on'); break;
}
});
/* ---- overlay: zoom rectangle + crosshair reticle ---- */
function drawOverlay() {
octx.clearRect(0, 0, cssW, cssH);
if (drag && drag.mode === 'rect' && drag.moved) {
const x = Math.min(drag.x0, drag.x1), y = Math.min(drag.y0, drag.y1);
const w = Math.abs(drag.x1 - drag.x0), h = Math.abs(drag.y1 - drag.y0);
octx.fillStyle = 'rgba(255,255,255,0.08)';
octx.fillRect(x, y, w, h);
octx.globalCompositeOperation = 'difference';
octx.strokeStyle = '#fff'; octx.lineWidth = 1.5;
octx.setLineDash([6, 4]);
octx.strokeRect(x + .5, y + .5, w, h);
octx.setLineDash([]);
octx.globalCompositeOperation = 'source-over';
}
if (state.previewOn && state.set === 'mandel' && mouse.over && !drag) {
octx.globalCompositeOperation = 'difference';
octx.strokeStyle = 'rgba(255,255,255,.55)'; octx.lineWidth = 1;
octx.beginPath();
octx.moveTo(mouse.x + .5, 0); octx.lineTo(mouse.x + .5, cssH);
octx.moveTo(0, mouse.y + .5); octx.lineTo(cssW, mouse.y + .5);
octx.stroke();
octx.globalCompositeOperation = 'source-over';
}
}
/* =====================================================================
9. JULIA LIVE PREVIEW
===================================================================== */
const pvCanvas = document.getElementById('preview');
const pvCtx = pvCanvas.getContext('2d');
const pvOff = document.createElement('canvas');
pvOff.width = CFG.PREVIEW_RES; pvOff.height = CFG.PREVIEW_RES;
const pvOffCtx = pvOff.getContext('2d');
const pvImg = new ImageData(CFG.PREVIEW_RES, CFG.PREVIEW_RES);
const pvMu = new Float32Array(CFG.PREVIEW_RES * CFG.PREVIEW_RES);
let pvDirty = false;
function renderPreview() {
previewWrap.classList.toggle('on', state.previewOn && state.set === 'mandel');
if (!previewWrap.classList.contains('on') || !mouse.over) return;
const w = CFG.PREVIEW_RES, h = CFG.PREVIEW_RES;
const cr = px2re(mouse.x), ci = px2im(mouse.y);
const span = 3.4, ppu = span / w;
for (let y = 0; y < h; y++) {
const im = -span / 2 + (y + 0.5) * ppu;
for (let x = 0; x < w; x++) {
iterJulia(-span / 2 + (x + 0.5) * ppu, im, CFG.PREVIEW_ITER, cr, ci);
pvMu[y * w + x] = r_mu;
}
}
colorize(pvMu, pvImg, w * h);
pvOffCtx.putImageData(pvImg, 0, 0);
pvCtx.imageSmoothingEnabled = true;
pvCtx.clearRect(0, 0, 180, 180);
pvCtx.drawImage(pvOff, 0, 0, 180, 180);
}
/* =====================================================================
10. BOOKMARKS + URL HASH
===================================================================== */
const BM_KEY = 'fractalExplorer.bookmarks.v1';
function getState() {
return { set: state.set, cx: state.cx, cy: state.cy, viewW: state.viewW,
jcRe: state.jcRe, jcIm: state.jcIm, palette: state.palette,
autoIter: state.autoIter, iterOverride: state.iterOverride,
cycleOffset: state.cycleOffset };
}
function applyState(s) {
Object.assign(state, s);
buildLUT(); buildChips(); syncControls();
computeMaxIter(); requestRender();
}
const loadBms = () => { try { return JSON.parse(localStorage.getItem(BM_KEY)) || []; } catch (e) { return []; } };
function saveBms(a) { localStorage.setItem(BM_KEY, JSON.stringify(a)); renderBmList(); }
function saveBookmark() {
const a = loadBms();
const name = prompt('Bookmark name:', 'view ' + (a.length + 1));
if (!name) return;
a.push({ name, state: getState() });
saveBms(a); toast('★ Bookmark saved');
}
function renderBmList() {
const a = loadBms();
bmList.innerHTML = a.length ? '' : '<div class="empty">No bookmarks yet — press S to save one.</div>';
a.forEach((b, i) => {
const row = document.createElement('div'); row.className = 'bm';
const go = document.createElement('button'); go.className = 'go';
go.textContent = b.name;
go.onclick = () => { applyState(b.state); bookmarksEl.classList.remove('on'); toast('Restored "' + b.name + '"'); };
const del = document.createElement('button'); del.className = 'del'; del.textContent = '×';
del.onclick = () => { const arr = loadBms(); arr.splice(i, 1); saveBms(arr); };
row.append(go, del); bmList.appendChild(row);
});
}
function updateHash() {
try { history.replaceState(null, '', '#' + btoa(JSON.stringify(getState()))); } catch (e) {}
}
function applyHash() {
if (location.hash.length < 2) return false;
try { applyState(JSON.parse(atob(location.hash.slice(1)))); return true; } catch (e) { return false; }
}
/* =====================================================================
11. HUD + CONTROLS WIRING
===================================================================== */
const hud = document.getElementById('hud'), hudSet = document.getElementById('hudSet'),
hudMag = document.getElementById('hudMag'), hudC = document.getElementById('hudC'),
hudIter = document.getElementById('hudIter'), hudFps = document.getElementById('hudFps'),
warnEl = document.getElementById('warn'), toastEl = document.getElementById('toast'),
helpEl = document.getElementById('help'), bookmarksEl = document.getElementById('bookmarks'),
bmList = document.getElementById('bmList'), previewWrap = document.getElementById('previewWrap'),
palGrp = document.getElementById('palGrp'), cycSlider = document.getElementById('cycSlider'),
cycAnim = document.getElementById('cycAnim'), autoIterEl = document.getElementById('autoIter'),
iterSlider = document.getElementById('iterSlider'), iterLabel = document.getElementById('iterLabel');
function updateHUD() {
hudSet.textContent = state.set === 'mandel' ? 'MANDELBROT' : 'JULIA c = ' + state.jcRe.toFixed(6) + ' + ' + state.jcIm.toFixed(6) + 'i';
hudMag.textContent = '×' + magnification().toExponential(2);
hudC.textContent = state.cx.toPrecision(12) + ', ' + state.cy.toPrecision(12);
hudIter.textContent = maxIter + (state.autoIter ? ' (auto)' : '');
iterLabel.textContent = state.autoIter ? maxIter : state.iterOverride;
}
let toastTimer = null;
function toast(msg) {
toastEl.textContent = msg; toastEl.classList.add('on');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('on'), 1800);
}
function buildChips() {
palGrp.querySelectorAll('.chip').forEach(c => c.remove());
PALETTES.forEach((p, i) => {
const b = document.createElement('button');
b.className = 'chip' + (i === state.palette ? ' sel' : '');
b.title = p.name;
b.style.background = 'linear-gradient(90deg,' + p.stops.map(([pos, h]) => h + ' ' + (pos * 100) + '%').join(',') + ')';
b.onclick = () => { state.palette = i; buildLUT(); buildChips(); recolorOnly(); };
palGrp.appendChild(b);
});
}
function syncControls() {
cycSlider.value = Math.round(state.cycleOffset * 1000);
cycAnim.checked = state.cycleAnim;
autoIterEl.checked = state.autoIter;
iterSlider.value = state.autoIter ? maxIter : state.iterOverride;
}
cycSlider.oninput = () => { state.cycleOffset = cycSlider.value / 1000; recolorOnly(); };
cycAnim.onchange = () => { state.cycleAnim = cycAnim.checked; };
autoIterEl.onchange = () => { state.autoIter = autoIterEl.checked; if (!state.autoIter) { state.iterOverride = maxIter; iterSlider.value = maxIter; } computeMaxIter(); requestRender(); };
iterSlider.oninput = () => {
state.iterOverride = +iterSlider.value;
if (state.autoIter) { state.autoIter = false; autoIterEl.checked = false; }
computeMaxIter(); updateHUD();
if (fullMu) requestRender();
};
document.getElementById('btnBack').onclick = back;
document.getElementById('btnReset').onclick = resetView;
document.getElementById('btnSave').onclick = saveBookmark;
document.getElementById('btnBm').onclick = () => { bookmarksEl.classList.toggle('on'); renderBmList(); };
document.getElementById('btnHelp').onclick = () => helpEl.classList.toggle('on');
addEventListener('mousemove', () => { lastActivity = performance.now(); }, { passive: true });
/* =====================================================================
12. DEBUG INTERFACE (window.fractal)
===================================================================== */
window.fractal = {
version: '1.0',
iterationsAt(re, im, set) {
iterate(re, im, maxIter, set);
return { iters: r_n, smooth: r_mu, escaped: r_escaped, interior: !r_escaped, maxIter };
},
setView(v) { setView(v); },
getView() { return getState(); },
rectZoom, zoomAt, back, reset: resetView,
maxIterations: () => maxIter,
state, CFG, PALETTES,
render: requestRender,
renderPreview,
};
/* =====================================================================
13. INIT
===================================================================== */
buildLUT();
buildChips();
resize();
if (!applyHash()) computeMaxIter();
requestRender();
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"0e089e31-bc2a-441a-8d59-d7442d0b71c4","tokensIn":1190936,"tokensOut":33369,"tokensTotal":1224305,"cost":0.081874735,"turns":27,"toolCalls":29,"failedToolCalls":0,"timestamp":"2026-08-28T02:22:09.488Z"} -->