Fractal Explorer
Qwen 3.8 · 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 48,497 bytes · SHA-256 5a4fed269fbe
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
/* ============================================================
SECTION 1 — GLOBAL / CANVAS
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color: #e8eaf0;
-webkit-font-smoothing: antialiased;
}
#view { position: fixed; inset: 0; display: block; cursor: crosshair; }
#overlay { position: fixed; inset: 0; pointer-events: none; mix-blend-mode: difference; }
.mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
/* ============================================================
SECTION 2 — GLASS HUD
============================================================ */
.glass {
background: rgba(13, 15, 21, 0.58);
backdrop-filter: blur(14px) saturate(1.35);
-webkit-backdrop-filter: blur(14px) saturate(1.35);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.38);
}
#hud { position: fixed; inset: 0; pointer-events: none; transition: opacity 0.55s ease; }
#hud.idle { opacity: 0; }
#hud .glass, #hud button, #hud input, #hud .chip, #hud .bm-item, #hud .bm-del { pointer-events: auto; }
#hud.idle * { pointer-events: none !important; }
#topbar {
position: absolute; top: 12px; left: 12px;
display: flex; gap: 18px; align-items: stretch; padding: 8px 16px;
}
.tb-cell { display: flex; flex-direction: column; justify-content: center; gap: 2px; min-width: 52px; }
.tb-cell .lbl {
font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em;
color: rgba(255, 255, 255, 0.45);
}
.tb-cell .val { font-size: 13px; color: #fff; white-space: nowrap; }
#hud-c-cell { display: none; }
#hud-c-cell.show { display: flex; }
#bottombar {
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
display: flex; gap: 10px; align-items: center; padding: 8px 14px;
max-width: calc(100vw - 24px); flex-wrap: wrap; justify-content: center;
}
#bottombar button {
background: rgba(255, 255, 255, 0.08); color: #e8eaf0;
border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 8px;
padding: 6px 11px; font-size: 12px; cursor: pointer;
transition: background 0.15s ease;
}
#bottombar button:hover { background: rgba(255, 255, 255, 0.18); }
#bottombar button.on { background: rgba(125, 211, 252, 0.30); border-color: rgba(125, 211, 252, 0.55); }
.sep { width: 1px; height: 22px; background: rgba(255, 255, 255, 0.14); }
.ctl { display: flex; align-items: center; gap: 7px; font-size: 11px; color: rgba(255, 255, 255, 0.6); }
.ctl input[type=range] { accent-color: #7dd3fc; width: 92px; }
#iter-readout { font-size: 11px; color: rgba(255,255,255,0.75); min-width: 34px; }
#chips { display: flex; gap: 6px; }
.chip {
width: 36px; height: 18px; border-radius: 5px; padding: 0;
border: 1px solid rgba(255, 255, 255, 0.28); cursor: pointer;
transition: transform 0.12s ease;
}
.chip:hover { transform: translateY(-1px); }
.chip.sel { outline: 2px solid #fff; outline-offset: 1px; }
/* ============================================================
SECTION 3 — JULIA PREVIEW PANE
============================================================ */
#preview-wrap {
position: absolute; top: 12px; right: 12px; padding: 8px 8px 6px;
opacity: 0; transform: translateY(-4px); transition: opacity 0.25s ease, transform 0.25s ease;
}
#preview-wrap.show { opacity: 1; transform: translateY(0); }
#preview { width: 150px; height: 150px; display: block; border-radius: 6px; }
#preview-label {
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 10px;
color: rgba(255, 255, 255, 0.62); margin-top: 6px; text-align: center; white-space: nowrap;
}
/* ============================================================
SECTION 4 — PANELS (BOOKMARKS / HELP), TOAST
============================================================ */
#bm-panel, #help-panel {
position: absolute; right: 12px; bottom: 74px; width: 292px; padding: 14px;
display: none;
}
#bm-panel.open, #help-panel.open { display: block; }
.panel-title {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.55); margin-bottom: 10px;
}
#bm-row { display: flex; gap: 6px; }
#bm-name {
flex: 1; min-width: 0; background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 8px;
color: #fff; font-size: 12px; padding: 6px 9px; outline: none;
}
#bm-name:focus { border-color: rgba(125, 211, 252, 0.6); }
#bm-save {
background: rgba(255, 255, 255, 0.10); border: 1px solid rgba(255, 255, 255, 0.18);
color: #e8eaf0; border-radius: 8px; padding: 6px 12px; font-size: 12px; cursor: pointer;
}
#bm-save:hover { background: rgba(255, 255, 255, 0.2); }
#bm-list { max-height: 236px; overflow-y: auto; display: flex; flex-direction: column; gap: 5px; margin-top: 10px; }
#bm-list::-webkit-scrollbar { width: 8px; }
#bm-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.18); border-radius: 4px; }
.bm-item {
display: flex; justify-content: space-between; align-items: center; gap: 8px;
background: rgba(255, 255, 255, 0.06); border-radius: 8px;
padding: 7px 9px; font-size: 12px; cursor: pointer;
}
.bm-item:hover { background: rgba(255, 255, 255, 0.14); }
.bm-item .nm { font-weight: 600; }
.bm-item .meta { color: rgba(255, 255, 255, 0.5); font-size: 10px; font-family: ui-monospace, Menlo, monospace; }
.bm-del {
opacity: 0.45; background: none; border: none; color: #fff; font-size: 13px;
cursor: pointer; padding: 2px 4px; border-radius: 5px;
}
.bm-del:hover { opacity: 1; background: rgba(255, 80, 80, 0.3); }
#bm-empty { font-size: 11px; color: rgba(255, 255, 255, 0.4); padding: 4px 2px; }
.help-list { list-style: none; display: flex; flex-direction: column; gap: 7px; font-size: 12px; color: rgba(255,255,255,0.8); }
.help-list li { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
kbd {
background: rgba(255, 255, 255, 0.13); border: 1px solid rgba(255,255,255,0.18);
border-radius: 5px; padding: 1px 6px; font-family: ui-monospace, Menlo, monospace;
font-size: 10.5px; color: #fff; white-space: nowrap;
}
#toast {
position: absolute; bottom: 86px; left: 50%;
transform: translateX(-50%) translateY(10px);
padding: 8px 18px; font-size: 12px; opacity: 0;
transition: opacity 0.3s ease, transform 0.3s ease; white-space: nowrap;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
</style>
</head>
<body>
<canvas id="view"></canvas>
<canvas id="overlay"></canvas>
<div id="hud">
<!-- top readout -->
<div id="topbar" class="glass">
<div class="tb-cell"><div class="lbl">set</div><div class="val mono" id="hud-mode">Mandelbrot</div></div>
<div class="tb-cell"><div class="lbl">mag</div><div class="val mono" id="hud-mag">×1.00</div></div>
<div class="tb-cell"><div class="lbl">center</div><div class="val mono" id="hud-center">—</div></div>
<div class="tb-cell" id="hud-c-cell"><div class="lbl">c</div><div class="val mono" id="hud-c">—</div></div>
<div class="tb-cell"><div class="lbl">iter</div><div class="val mono" id="hud-iters">—</div></div>
<div class="tb-cell"><div class="lbl">fps</div><div class="val mono" id="hud-fps">—</div></div>
</div>
<!-- Julia live preview -->
<div id="preview-wrap" class="glass">
<canvas id="preview" width="128" height="128"></canvas>
<div id="preview-label">Julia c = —</div>
</div>
<!-- bottom controls -->
<div id="bottombar" class="glass">
<button id="btn-back" title="Back (B)">⟲</button>
<button id="btn-reset" title="Reset view (0)">⌂</button>
<button id="btn-mode" title="Toggle Julia set (J)">Julia (J)</button>
<span class="sep"></span>
<div id="chips" title="Palette (1–4)"></div>
<span class="sep"></span>
<label class="ctl" title="Color drift offset (recolor only — no recompute)">drift
<input id="sl-offset" type="range" min="0" max="1023" value="0" step="1">
</label>
<label class="ctl" title="Iteration multiplier (re-renders)">iter
<input id="sl-iter" type="range" min="0.5" max="8" value="1" step="0.25">
<span id="iter-readout">×1</span>
</label>
<button id="btn-anim" title="Animate color drift (A)">▶</button>
<span class="sep"></span>
<button id="btn-bm" title="Bookmarks (S)">★</button>
<button id="btn-help" title="Help (H)">?</button>
</div>
<!-- bookmarks -->
<div id="bm-panel" class="glass">
<div class="panel-title">Bookmarks</div>
<div id="bm-row">
<input id="bm-name" placeholder="name…" maxlength="24" spellcheck="false">
<button id="bm-save">Save</button>
</div>
<div id="bm-list"></div>
</div>
<!-- help -->
<div id="help-panel" class="glass">
<div class="panel-title">Controls</div>
<ul class="help-list">
<li><kbd>drag</kbd> rectangle zoom (release to zoom)</li>
<li><kbd>shift+drag</kbd> / <kbd>right-drag</kbd> pan, flick for momentum</li>
<li><kbd>wheel</kbd> zoom toward cursor</li>
<li><kbd>click</kbd> on Mandelbrot → open Julia set for that c</li>
<li><kbd>J</kbd> toggle Julia · <kbd>M</kbd> back to Mandelbrot</li>
<li><kbd>← ↑ → ↓</kbd> pan · <kbd>+</kbd> <kbd>−</kbd> zoom · <kbd>0</kbd> reset</li>
<li><kbd>B</kbd> history back · <kbd>1–4</kbd> palette</li>
<li><kbd>A</kbd> color drift · <kbd>S</kbd> bookmark · <kbd>H</kbd> help</li>
<li>hover (Mandelbrot) → live Julia preview + reticle</li>
</ul>
</div>
<div id="toast" class="glass"></div>
</div>
<script>
'use strict';
/* ================================================================
SECTION 5 — TUNABLE CONSTANTS
================================================================ */
const CFG = {
INITIAL: { re: -0.5, im: 0.0, width: 3.5 }, // starting view (complex width)
BASE_ITER: 200, // iteration limit at magnification ×1
ITER_PER_LOG10: 250, // extra iterations per decade of magnification
MAX_ITER_HARD: 12000, // absolute cap
MIN_ITER: 50,
TILE: 64, // full-res tile size in device px
PASSES: [0.25, 0.5, 1.0], // progressive refinement fractions (coarse → full)
WORKERS: 0, // 0 = auto (2..6)
LUT_SIZE: 1024,
ZOOM_WHEEL: 0.0015, // wheel: factor = exp(-deltaY * ZOOM_WHEEL)
ZOOM_KEY: 1.5, // per-press zoom factor for + / -
MAX_MAG: 1e13, // hard precision cap (double-precision limit)
WARN_MAG: 5e12, // warn threshold
MIN_MAG: 1e-4, // maximum zoom-out
DRAG_THRESHOLD: 4, // px before a press counts as a drag (else click)
PREVIEW: { size: 128, iter: 300, throttle: 45, width: 3.5 },
IDLE_HIDE_MS: 3000, // HUD auto-hide delay
ANIM_SPEED: 60, // drift LUT units per second
MOMENTUM_DECAY: 0.0025,// velocity retention per second
HISTORY_MAX: 100,
DPR_CAP: 1.5,
};
/* ================================================================
SECTION 6 — UTILITIES
================================================================ */
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
function fmtMag(m) {
if (m >= 100) return '×' + m.toExponential(2).replace('e+', 'e');
return '×' + m.toFixed(2);
}
function fmtCoord(v) {
if (v === 0) return '0';
const a = Math.abs(v);
if (a >= 1e-3 && a < 1e3) return v.toPrecision(10);
return v.toExponential(6);
}
function fmtC(re, im) {
return fmtCoord(re) + (im < 0 ? ' − ' : ' + ') + fmtCoord(Math.abs(im)) + 'i';
}
/* ================================================================
SECTION 7 — PALETTES + LUT
================================================================ */
const PALETTES = [
{ name: 'Fire', cyclic: false, stops: [
[0, '#050100'], [0.22, '#3d0c02'], [0.45, '#c33611'],
[0.62, '#ff8c1a'], [0.80, '#ffd94a'], [1, '#fffbe6'] ] },
{ name: 'Ocean', cyclic: false, stops: [
[0, '#000208'], [0.25, '#021a4a'], [0.50, '#0a5fa8'],
[0.70, '#2ec4b6'], [0.85, '#a5f3e0'], [1, '#eafff9'] ] },
{ name: 'Mono', cyclic: false, stops: [
[0, '#000000'], [1, '#ffffff'] ] },
{ name: 'Psychedelic', cyclic: true, stops: [
[0, '#ff004c'], [0.2, '#ffd000'], [0.4, '#00ff88'],
[0.6, '#00cfff'], [0.8, '#a04cff'], [1, '#ff004c'] ] },
];
(function precompute() {
for (const p of PALETTES) {
p.rgb = p.stops.map(s => [
parseInt(s[1].slice(1, 3), 16),
parseInt(s[1].slice(3, 5), 16),
parseInt(s[1].slice(5, 7), 16),
]);
}
})();
function sampleStops(pal, t) {
const st = pal.stops, rg = pal.rgb;
if (t <= 0) return rg[0];
if (t >= 1) return rg[rg.length - 1];
for (let i = 1; i < st.length; i++) {
if (t <= st[i][0]) {
const t0 = st[i - 1][0], c0 = rg[i - 1], c1 = rg[i];
const f = (t - t0) / (st[i][0] - t0);
return [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
}
}
return rg[rg.length - 1];
}
let LUT_R = new Uint8Array(CFG.LUT_SIZE),
LUT_G = new Uint8Array(CFG.LUT_SIZE),
LUT_B = new Uint8Array(CFG.LUT_SIZE);
function buildLUT(palIdx) {
const pal = PALETTES[clamp(palIdx, 0, PALETTES.length - 1)];
for (let i = 0; i < CFG.LUT_SIZE; i++) {
const c = sampleStops(pal, i / (CFG.LUT_SIZE - 1));
LUT_R[i] = c[0]; LUT_G[i] = c[1]; LUT_B[i] = c[2];
}
}
function paletteGradient(pal) {
return 'linear-gradient(90deg,' +
pal.stops.map(s => s[1] + ' ' + (s[0] * 100).toFixed(1) + '%').join(',') + ')';
}
/* ================================================================
SECTION 8 — WORKER (inline via Blob URL)
Escape-time iteration with smooth (continuous) coloring:
mu = n + 1 - log2( log|z| ) evaluated as n + 1 - log2( 0.5*ln(|z|^2) )
Interior points (n reaches maxIter) are tagged -1.
The per-pixel loop is allocation-free (primitives only).
================================================================ */
const WORKER_SRC = `
'use strict';
onmessage = function (e) {
const m = e.data;
if (m.type !== 'tile') return;
const w = m.w, h = m.h, maxIter = m.maxIter;
const out = new Float32Array(w * h);
const dx = m.dx, dy = m.dy, re0 = m.re0, im0 = m.im0;
const julia = m.mode === 1, cRe = m.cRe, cIm = m.cIm;
let i = 0;
for (let py = 0; py < h; py++) {
const zi0 = im0 + py * dy;
for (let px = 0; px < w; px++) {
const zr0 = re0 + px * dx;
let zr = zr0, zi = zi0;
let zr2 = zr * zr, zi2 = zi * zi;
const cr = julia ? cRe : zr0, ci = julia ? cIm : zi0;
let n = 0;
while (n < maxIter && zr2 + zi2 <= 4.0) {
zi = 2.0 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
n++;
}
if (n >= maxIter) out[i] = -1.0;
else out[i] = n + 1.0 - Math.log2(0.5 * Math.log(zr2 + zi2));
i++;
}
}
postMessage({ type: 'result', id: m.id, gen: m.gen, x: m.x, y: m.y,
w: w, h: h, maxIter: maxIter, buf: out }, [out.buffer]);
};
`;
const workerURL = URL.createObjectURL(new Blob([WORKER_SRC], { type: 'application/javascript' }));
/* ================================================================
SECTION 9 — RENDERER (tiles, passes, worker pool, colorize)
================================================================ */
const canvas = document.getElementById('view');
const ocanvas = document.getElementById('overlay');
const ctx = canvas.getContext('2d', { alpha: false });
const octx = ocanvas.getContext('2d');
const oldCanvas = document.createElement('canvas'); // previous-frame snapshot for blurry preview
let W = 0, H = 0, DPR = 1;
const renderer = {
gen: 0, // bumped on every view change → cancels stale work
queue: [], // pending tiles, sorted (pass, distance-from-center)
tiles: new Map(), // key -> { buf, w, h, maxIter, x, y }
workers: [],
tileSize: CFG.TILE,
};
const imgPool = new Map(); // (w,h) -> ImageData (reused, no per-frame alloc)
const coarsePool = new Map(); // w -> small offscreen canvas
function getImg(w, h) {
const k = w * 100000 + h;
let img = imgPool.get(k);
if (!img) { img = ctx.createImageData(w, h); imgPool.set(k, img); }
return img;
}
function getCoarse(w) {
let c = coarsePool.get(w);
if (!c) { c = document.createElement('canvas'); c.width = w; c.height = w; coarsePool.set(w, c); }
return c;
}
function computeGrid() {
const t = renderer.tileSize;
const cols = Math.ceil(W / t), rows = Math.ceil(H / t);
const q = renderer.queue;
q.length = 0;
for (let cy = 0; cy < rows; cy++) {
for (let cx = 0; cx < cols; cx++) {
const dx = (cx * t + t / 2) - W / 2;
const dy = (cy * t + t / 2) - H / 2;
q.push({ cx, cy, pass: 0, d: dx * dx + dy * dy, key: cx * 100000 + cy });
}
}
q.sort((a, b) => a.d - b.d);
}
function buildTask(tile) {
const v = activeView();
const t = renderer.tileSize;
const f = CFG.PASSES[tile.pass];
const rw = Math.max(4, Math.round(t * f));
const x = tile.cx * t, y = tile.cy * t;
const re0 = v.re + (x - W / 2) * v.scale;
const im0 = v.im - (y - H / 2) * v.scale;
const dx = (t * v.scale) / rw;
const isJ = state.active === 'j';
return {
type: 'tile', id: tile.key, gen: renderer.gen, x, y,
w: rw, h: rw, re0, im0, dx, dy: -dx,
maxIter: currentMaxIter(),
cRe: isJ ? state.j.cRe : 0, cIm: isJ ? state.j.cIm : 0,
mode: isJ ? 1 : 0,
};
}
function pump() {
if (renderer.hasWorkers) dispatch();
else dispatchFallback();
}
function dispatch() {
for (let i = 0; i < renderer.workers.length; i++) {
const wk = renderer.workers[i];
if (wk.busy || wk.dead) continue;
const tile = renderer.queue.shift();
if (!tile) break;
wk.busy = true;
wk.task = tile;
wk.w.postMessage(buildTask(tile));
}
}
function onResult(wk, e) {
wk.busy = false;
const m = e.data;
if (m.gen !== renderer.gen) { pump(); return; } // view moved → drop
const tile = wk.task;
const T = renderer.tileSize;
renderer.tiles.set(tile.key, {
buf: m.buf, w: m.w, h: m.h, maxIter: m.maxIter,
x: tile.cx * T, y: tile.cy * T,
});
blitTile(tile, m);
tile.pass++;
if (tile.pass < CFG.PASSES.length) {
renderer.queue.push(tile);
renderer.queue.sort((a, b) => (a.pass - b.pass) || (a.d - b.d));
}
pump();
}
function blitTile(tile, m) {
const img = getImg(m.w, m.h);
colorize(img.data, m.buf, m.maxIter);
const T = renderer.tileSize;
if (m.w === T) {
ctx.putImageData(img, tile.cx * T, tile.cy * T);
} else {
const c = getCoarse(m.w);
c.getContext('2d').putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(c, tile.cx * T, tile.cy * T, T, T);
}
}
/* Colorize pass: smooth value -> LUT. Interior (v < 0) -> black.
t = sqrt(v / maxIter) spreads low-iteration colors; offset cycles drift. */
function colorize(data, buf, maxIter) {
const n = buf.length;
const norm = 1 / Math.sqrt(Math.max(1, maxIter));
const off = state.offset;
let j = 0;
for (let i = 0; i < n; i++) {
const v = buf[i];
if (v < 0) { data[j] = 0; data[j + 1] = 0; data[j + 2] = 0; }
else {
let idx = (Math.sqrt(v) * norm * CFG.LUT_SIZE + off) | 0;
if (idx >= CFG.LUT_SIZE) idx -= CFG.LUT_SIZE;
data[j] = LUT_R[idx]; data[j + 1] = LUT_G[idx]; data[j + 2] = LUT_B[idx];
}
data[j + 3] = 255;
j += 4;
}
}
/* Recolor everything already computed (palette / offset changes) —
never recomputes iterations. */
function recolorAll() {
const T = renderer.tileSize;
for (const tile of renderer.tiles.values()) {
const img = getImg(tile.w, tile.h);
colorize(img.data, tile.buf, tile.maxIter);
if (tile.w === T) {
ctx.putImageData(img, tile.x, tile.y);
} else {
const c = getCoarse(tile.w);
c.getContext('2d').putImageData(img, 0, 0);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(c, tile.x, tile.y, T, T);
}
}
}
/* Blurry instant preview: the old canvas is an affine (scale+translate)
image of the new view, so drawImage it with the exact transform. */
function previewFromOld(before) {
oldCanvas.width = W; oldCanvas.height = H;
oldCanvas.getContext('2d').drawImage(canvas, 0, 0);
const v = activeView();
const k = before.scale / v.scale;
const tx = W / 2 * (1 - k) + (before.re - v.re) / v.scale;
const ty = H / 2 * (1 - k) + (before.im - v.im) / v.scale;
ctx.setTransform(k, 0, 0, k, tx, ty);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(oldCanvas, 0, 0);
ctx.setTransform(1, 0, 0, 1, 0, 0);
}
function changeView(before) {
renderer.gen++;
renderer.queue.length = 0;
renderer.tiles.clear();
if (before) previewFromOld(before);
else { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H); }
computeGrid();
pump();
updateHUD();
updateHashSoon();
}
function initWorkers() {
const n = CFG.WORKERS || clamp(navigator.hardwareConcurrency || 4, 2, 6);
try {
for (let i = 0; i < n; i++) {
const w = new Worker(workerURL);
const wk = { w, busy: false, dead: false, task: null };
w.onmessage = (e) => onResult(wk, e);
w.onerror = (e) => {
wk.dead = true;
toast('Worker error: ' + (e.message || 'unknown') + ' — falling back to main thread');
};
renderer.workers.push(wk);
}
renderer.hasWorkers = true;
return true;
} catch (err) {
renderer.hasWorkers = false;
return false;
}
}
</script>
<script>
'use strict';
/* ================================================================
SECTION 10 — STATE & VIEW MATH
state.m / state.j both carry { re, im, width } (complex width of
the view); state.j additionally carries the c-value { cRe, cIm }.
scale = complex units per CSS pixel = width / Wcss.
================================================================ */
const state = {
active: 'm',
m: { re: CFG.INITIAL.re, im: CFG.INITIAL.im, width: CFG.INITIAL.width },
j: { cRe: -0.743643887037151, cIm: 0.13182590420533, re: 0, im: 0, width: 3.5 },
palette: 0,
iterMult: 1,
offset: 0,
anim: false,
history: [],
};
let Wcss = 1, Hcss = 1;
let hover = { x: 0, y: 0, on: false };
function activeView() {
const s = state[state.active];
return { re: s.re, im: s.im, scale: s.width / Wcss };
}
/* Magnification relative to the initial view (initial = ×1). */
function viewMag() {
const s = state[state.active];
return clamp(CFG.INITIAL.width / s.width, CFG.MIN_MAG, CFG.MAX_MAG);
}
/* Iteration limit scales with magnification: base + per-decade bonus,
multiplied by the manual slider, clamped to sane bounds. */
function currentMaxIter() {
const mag = Math.max(1, viewMag());
return clamp(
Math.round((CFG.BASE_ITER + CFG.ITER_PER_LOG10 * Math.log10(mag)) * state.iterMult),
CFG.MIN_ITER, CFG.MAX_ITER_HARD
);
}
/* ================================================================
SECTION 11 — JULIA LIVE PREVIEW (hover on Mandelbrot)
================================================================ */
const previewCanvas = document.getElementById('preview');
const pctx = previewCanvas.getContext('2d');
const previewImg = pctx.createImageData(CFG.PREVIEW.size, CFG.PREVIEW.size);
const previewWrap = document.getElementById('preview-wrap');
const previewLabel = document.getElementById('preview-label');
let lastPreviewT = 0, lastHoverC = null;
function previewRender(cRe, cIm) {
const S = CFG.PREVIEW.size, maxIter = CFG.PREVIEW.iter, scale = CFG.PREVIEW.width / S;
const data = previewImg.data;
const norm = 1 / Math.sqrt(maxIter), off = state.offset;
let j = 0;
for (let py = 0; py < S; py++) {
const zi0 = (py - S / 2) * scale;
for (let px = 0; px < S; px++) {
const zr0 = (px - S / 2) * scale;
let zr = zr0, zi = zi0, zr2 = zr * zr, zi2 = zi * zi, nn = 0;
while (nn < maxIter && zr2 + zi2 <= 4) {
zi = 2 * zr * zi + cIm;
zr = zr2 - zi2 + cRe;
zr2 = zr * zr; zi2 = zi * zi;
nn++;
}
const v = nn >= maxIter ? -1 : nn + 1 - Math.log2(0.5 * Math.log(zr2 + zi2));
if (v < 0) { data[j] = 0; data[j + 1] = 0; data[j + 2] = 0; }
else {
let idx = (Math.sqrt(v) * norm * CFG.LUT_SIZE + off) | 0;
if (idx >= CFG.LUT_SIZE) idx -= CFG.LUT_SIZE;
data[j] = LUT_R[idx]; data[j + 1] = LUT_G[idx]; data[j + 2] = LUT_B[idx];
}
data[j + 3] = 255;
j += 4;
}
}
pctx.putImageData(previewImg, 0, 0);
}
function schedulePreview() {
const now = performance.now();
if (now - lastPreviewT < CFG.PREVIEW.throttle) return;
const v = activeView();
const cRe = v.re + (hover.x - W / 2) * v.scale;
const cIm = v.im - (hover.y - H / 2) * v.scale;
if (lastHoverC && Math.abs(cRe - lastHoverC[0]) < v.scale * 0.5 &&
Math.abs(cIm - lastHoverC[1]) < v.scale * 0.5) return;
lastPreviewT = now;
lastHoverC = [cRe, cIm];
previewRender(cRe, cIm);
previewWrap.classList.add('show');
previewLabel.textContent = 'Julia c = ' + fmtC(cRe, cIm);
}
function hidePreview() {
previewWrap.classList.remove('show');
lastHoverC = null;
}
/* ================================================================
SECTION 12 — NAVIGATION & INPUT
drag = rectangle zoom · shift/right-drag = pan (with momentum)
click = open Julia at c · wheel = zoom toward cursor
================================================================ */
const drag = {
active: false, mode: null, button: 0,
sx: 0, sy: 0, lx: 0, ly: 0, vx: 0, vy: 0, lastT: 0, moved: false,
};
const momentum = { vx: 0, vy: 0, active: false };
let capWarnT = 0, keyNavT = 0, wheelT = 0;
function pushHistory() {
state.history.push({ mode: state.active, m: { ...state.m }, j: { ...state.j } });
if (state.history.length > CFG.HISTORY_MAX) state.history.shift();
}
function backHistory() {
const h = state.history.pop();
if (!h) { toast('Nothing to go back to'); return; }
state.active = h.mode;
Object.assign(state.m, h.m);
Object.assign(state.j, h.j);
hidePreview();
changeView(activeView());
toast('Back');
}
function panBy(dxDev, dyDev) {
const v = activeView();
const s = state[state.active];
const before = v;
s.re -= dxDev * v.scale;
s.im += dyDev * v.scale;
changeView(before);
}
function zoomAt(px, py, factor) {
const v = activeView();
const s = state[state.active];
let ns = v.scale * factor;
const minS = 1 / CFG.MAX_MAG, maxS = 1 / CFG.MIN_MAG;
if (ns < minS) {
ns = minS;
if (performance.now() - capWarnT > 1500) {
capWarnT = performance.now();
toast('Precision limit — double precision degrades beyond ×1e13');
}
}
if (ns > maxS) ns = maxS;
if (ns === v.scale) return;
const reP = v.re + (px - W / 2) * v.scale; // complex point under cursor
const imP = v.im - (py - H / 2) * v.scale;
const k = ns / v.scale; // new distance from anchor = old × (new/old scale)
const before = v;
s.re = reP + (v.re - reP) * k; // keep cursor point fixed
s.im = imP + (v.im - imP) * k;
s.width = ns * Wcss;
changeView(before);
}
/* Drag-zoom release: the selected pixel rectangle maps exactly to the
new view — center at the rectangle center, width = rectangle width.
The view keeps the canvas aspect ratio: the short side of the
rectangle matches the short side of the canvas, the long side is
extended symmetrically, so the framed region is always the rendered
region (no silent shift). */
function releaseRectZoom(x0, y0, x1, y1) {
const v = activeView();
const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
const rw = Math.abs(x1 - x0), rh = Math.abs(y1 - y0);
const ar = W / H;
const width = (rw >= rh * ar ? rw : rh * ar) * v.scale;
const re = v.re + (cx - W / 2) * v.scale;
const im = v.im - (cy - H / 2) * v.scale;
pushHistory();
const s = state[state.active];
s.re = re; s.im = im; s.width = width;
changeView(v);
}
function openJuliaAt(px, py) {
const v = activeView();
const cRe = v.re + (px - W / 2) * v.scale;
const cIm = v.im - (py - H / 2) * v.scale;
state.j = { cRe, cIm, re: cRe, im: cIm, width: 3.5 };
pushHistory();
state.active = 'j';
hidePreview();
changeView(activeView());
}
function toggleMode() {
pushHistory();
state.active = state.active === 'm' ? 'j' : 'm';
if (state.active === 'j') hidePreview();
changeView(activeView());
}
function resetView() {
pushHistory();
state.m.re = CFG.INITIAL.re;
state.m.im = CFG.INITIAL.im;
state.m.width = CFG.INITIAL.width;
state.active = 'm';
hidePreview();
changeView(activeView());
}
/* Public (debug) view setter — clamps to the precision caps. */
function setViewPub(v) {
const s = state[state.active];
let scale = v.scale;
if (!scale && v.width) scale = v.width / Wcss;
if (!scale) return;
let mag = 1 / scale;
if (mag > CFG.MAX_MAG) scale = 1 / CFG.MAX_MAG;
if (mag < CFG.MIN_MAG) scale = 1 / CFG.MIN_MAG;
const before = activeView();
s.re = v.re; s.im = v.im; s.width = scale * Wcss;
changeView(before);
}
function initInput() {
canvas.addEventListener('pointerdown', onDown);
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
canvas.addEventListener('wheel', onWheel, { passive: false });
canvas.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('keydown', onKey);
}
function onDown(e) {
wake();
const px = e.clientX * DPR, py = e.clientY * DPR;
drag.active = true;
drag.moved = false;
drag.button = e.button;
drag.mode = (e.shiftKey || e.button === 2) ? 'pan' : 'rect';
drag.sx = drag.lx = px; drag.sy = drag.ly = py;
drag.vx = drag.vy = 0;
drag.lastT = performance.now();
try { canvas.setPointerCapture(e.pointerId); } catch (err) { /* synthetic events */ }
if (drag.mode === 'pan') pushHistory();
}
function onMove(e) {
wake();
const px = e.clientX * DPR, py = e.clientY * DPR;
hover.x = px; hover.y = py;
hover.on = e.target === canvas;
if (!drag.active) {
if (state.active === 'm' && hover.on) schedulePreview();
if (state.active === 'm' && hover.on) updateHUD();
return;
}
if (Math.hypot(px - drag.sx, py - drag.sy) > CFG.DRAG_THRESHOLD) drag.moved = true;
if (!drag.moved) return;
const now = performance.now(), dt = Math.max(1, now - drag.lastT);
if (drag.mode === 'pan') {
panBy(px - drag.lx, py - drag.ly);
drag.vx = 0.75 * drag.vx + 0.25 * ((px - drag.lx) / dt);
drag.vy = 0.75 * drag.vy + 0.25 * ((py - drag.ly) / dt);
}
drag.lx = px; drag.ly = py; drag.lastT = now;
}
function onUp(e) {
if (!drag.active) return;
drag.active = false;
const px = e.clientX * DPR, py = e.clientY * DPR;
if (drag.moved) {
if (drag.mode === 'rect') {
if (Math.abs(px - drag.sx) > 3 && Math.abs(py - drag.sy) > 3)
releaseRectZoom(drag.sx, drag.sy, px, py);
} else if (Math.hypot(drag.vx, drag.vy) > 0.02) {
momentum.vx = drag.vx; momentum.vy = drag.vy; momentum.active = true;
}
} else if (state.active === 'm' && e.button === 0) {
openJuliaAt(px, py);
}
}
function onWheel(e) {
e.preventDefault();
wake();
const px = e.clientX * DPR, py = e.clientY * DPR;
zoomAt(px, py, Math.exp(e.deltaY * CFG.ZOOM_WHEEL)); // wheel up (deltaY<0) → zoom in
const now = performance.now();
if (now - wheelT > 250) pushHistory();
wheelT = now;
}
function onKey(e) {
if (e.target && e.target.tagName === 'INPUT') return;
wake();
const k = e.key;
const cx = W / 2, cy = H / 2;
const step = 40 * DPR;
let handled = true;
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
const now = performance.now();
if (now - keyNavT > 400) pushHistory();
keyNavT = now;
if (k === 'ArrowLeft') panBy(step, 0);
else if (k === 'ArrowRight') panBy(-step, 0);
else if (k === 'ArrowUp') panBy(0, step);
else panBy(0, -step);
} else if (k === '+' || k === '=') { pushHistory(); zoomAt(cx, cy, 1 / CFG.ZOOM_KEY); }
else if (k === '-' || k === '_') { pushHistory(); zoomAt(cx, cy, CFG.ZOOM_KEY); }
else if (k === '0') resetView();
else if (k === 'b' || k === 'B') backHistory();
else if (k === 'j' || k === 'J') toggleMode();
else if (k === 'm' || k === 'M') { if (state.active === 'j') toggleMode(); }
else if (k >= '1' && k <= '4') palette(+k - 1);
else if (k === 'a' || k === 'A') toggleAnim();
else if (k === 's' || k === 'S') togglePanel('bm');
else if (k === 'h' || k === 'H') togglePanel('help');
else if (k === 'Escape') closePanels();
else handled = false;
if (handled) { e.preventDefault(); updateHUD(); }
}
/* ================================================================
SECTION 12b — MAIN-THREAD FALLBACK RENDERER
Used automatically when Workers are unavailable (e.g. some
file:// contexts): same math, a few tiles per frame.
================================================================ */
function computeTileMain(task) {
const out = new Float32Array(task.w * task.h);
const dx = task.dx, dy = task.dy, re0 = task.re0, im0 = task.im0;
const maxIter = task.maxIter, julia = task.mode === 1, cRe = task.cRe, cIm = task.cIm;
let i = 0;
for (let py = 0; py < task.h; py++) {
const zi0 = im0 + py * dy;
for (let px = 0; px < task.w; px++) {
const zr0 = re0 + px * dx;
let zr = zr0, zi = zi0, zr2 = zr * zr, zi2 = zi * zi;
const cr = julia ? cRe : zr0, ci = julia ? cIm : zi0;
let n = 0;
while (n < maxIter && zr2 + zi2 <= 4.0) {
zi = 2.0 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
n++;
}
out[i++] = n >= maxIter ? -1.0 : n + 1.0 - Math.log2(0.5 * Math.log(zr2 + zi2));
}
}
return out;
}
function dispatchFallback() {
let n = 0;
while (n < 3) {
const tile = renderer.queue.shift();
if (!tile) break;
const task = buildTask(tile);
const buf = computeTileMain(task);
onResult({ task }, { data: {
type: 'result', id: task.id, gen: task.gen, x: task.x, y: task.y,
w: task.w, h: task.h, maxIter: task.maxIter, buf,
} });
n++;
}
}
</script>
<script>
'use strict';
/* ================================================================
SECTION 13 — UI WIRING (HUD, chips, panels, toast, idle-hide)
================================================================ */
const el = id => document.getElementById(id);
const hud = el('hud'), toastEl = el('toast');
const hudMode = el('hud-mode'), hudMag = el('hud-mag'), hudCenter = el('hud-center'),
hudC = el('hud-c'), hudCCell = el('hud-c-cell'), hudIters = el('hud-iters'),
hudFps = el('hud-fps'), btnMode = el('btn-mode'), chipsEl = el('chips');
let toastTimer = 0, idleTimer = 0, lastActivity = performance.now();
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2200);
}
function wake() {
lastActivity = performance.now();
hud.classList.remove('idle');
clearTimeout(idleTimer);
idleTimer = setTimeout(() => hud.classList.add('idle'), CFG.IDLE_HIDE_MS);
}
function updateHUD() {
const v = activeView();
const mag = viewMag();
hudMode.textContent = state.active === 'm' ? 'Mandelbrot' : 'Julia';
hudMag.textContent = fmtMag(mag);
const s = state[state.active];
hudCenter.textContent = fmtC(s.re, s.im);
if (state.active === 'j') {
hudCCell.classList.add('show');
hudC.textContent = fmtC(state.j.cRe, state.j.cIm);
} else {
hudCCell.classList.remove('show');
}
hudIters.textContent = currentMaxIter();
btnMode.textContent = state.active === 'm' ? 'Julia (J)' : 'Mandelbrot (J)';
if (state.active === 'm' && hover.on) {
const cRe = v.re + (hover.x - W / 2) * v.scale;
const cIm = v.im - (hover.y - H / 2) * v.scale;
hudCCell.classList.add('show');
hudC.textContent = 'c = ' + fmtC(cRe, cIm);
}
}
function buildChips() {
chipsEl.innerHTML = '';
PALETTES.forEach((p, i) => {
const b = document.createElement('button');
b.className = 'chip' + (i === state.palette ? ' sel' : '');
b.style.background = paletteGradient(p);
b.title = p.name + ' (' + (i + 1) + ')';
b.setAttribute('aria-label', 'palette ' + p.name);
b.addEventListener('click', () => palette(i));
chipsEl.appendChild(b);
});
}
function palette(i) {
state.palette = i;
buildLUT(i);
recolorAll();
buildChips();
updateHUD();
updateHashSoon();
}
function toggleAnim() {
state.anim = !state.anim;
el('btn-anim').textContent = state.anim ? '❚❚' : '▶';
el('btn-anim').classList.toggle('on', state.anim);
el('sl-offset').classList.toggle('on', state.anim);
}
function togglePanel(which) {
const bm = el('bm-panel'), hp = el('help-panel');
if (which === 'bm') { bm.classList.toggle('open'); hp.classList.remove('open'); }
else { hp.classList.toggle('open'); bm.classList.remove('open'); }
}
function closePanels() {
el('bm-panel').classList.remove('open');
el('help-panel').classList.remove('open');
}
function wireUI() {
el('btn-back').addEventListener('click', () => { wake(); backHistory(); });
el('btn-reset').addEventListener('click', () => { wake(); resetView(); });
btnMode.addEventListener('click', () => { wake(); toggleMode(); });
el('btn-anim').addEventListener('click', () => { wake(); toggleAnim(); });
el('btn-bm').addEventListener('click', () => { wake(); togglePanel('bm'); renderBmList(); });
el('btn-help').addEventListener('click', () => { wake(); togglePanel('help'); });
el('sl-offset').addEventListener('input', e => {
state.offset = +e.target.value;
recolorAll();
if (state.active === 'm' && hover.on) schedulePreview();
});
el('sl-iter').addEventListener('input', e => {
state.iterMult = +e.target.value;
el('iter-readout').textContent = '×' + state.iterMult;
updateHUD();
changeView(activeView());
updateHashSoon();
});
el('bm-save').addEventListener('click', saveBookmark);
el('bm-name').addEventListener('keydown', e => { if (e.key === 'Enter') saveBookmark(); });
}
/* ================================================================
SECTION 14 — BOOKMARKS (localStorage) & URL-HASH STATE
================================================================ */
const LS_KEY = 'fractal-explorer.bookmarks.v1';
function loadBookmarks() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]'); }
catch (err) { return []; }
}
function persistBookmarks(list) {
try { localStorage.setItem(LS_KEY, JSON.stringify(list)); }
catch (err) { toast('Bookmarks unavailable (storage blocked)'); }
}
function viewSnapshot() {
return {
mode: state.active,
re: state[state.active].re, im: state[state.active].im,
scale: state[state.active].width / Wcss,
palette: state.palette, iterMult: state.iterMult, offset: state.offset,
cRe: state.j.cRe, cIm: state.j.cIm,
};
}
/* Restore exactly: re/im/scale + palette/iterations + Julia c. */
function restoreSnapshot(snap) {
state.palette = snap.palette | 0;
state.iterMult = +snap.iterMult || 1;
state.offset = snap.offset | 0;
el('sl-iter').value = state.iterMult;
el('iter-readout').textContent = '×' + state.iterMult;
el('sl-offset').value = state.offset;
buildLUT(state.palette);
buildChips();
state.active = snap.mode === 'j' ? 'j' : 'm';
const s = state[state.active];
s.re = +snap.re; s.im = +snap.im;
s.width = (+snap.scale) * Wcss;
state.j.cRe = +snap.cRe; state.j.cIm = +snap.cIm;
hidePreview();
changeView(activeView());
}
function saveBookmark() {
const name = el('bm-name').value.trim() || 'view ' + new Date().toLocaleTimeString();
const list = loadBookmarks();
list.push(Object.assign({ name, t: Date.now() }, viewSnapshot()));
persistBookmarks(list);
el('bm-name').value = '';
renderBmList();
toast('Saved “' + name + '”');
}
function renderBmList() {
const list = el('bm-list');
const items = loadBookmarks();
list.innerHTML = '';
if (!items.length) {
list.innerHTML = '<div id="bm-empty">No bookmarks yet — name a view and hit Save.</div>';
return;
}
items.slice().reverse().forEach((b, ri) => {
const idx = items.length - 1 - ri;
const row = document.createElement('div');
row.className = 'bm-item';
row.innerHTML = '<span class="nm"></span><span class="meta"></span>';
row.querySelector('.nm').textContent = b.name;
const mag = 1 / (b.scale || 1);
row.querySelector('.meta').textContent =
(b.mode === 'j' ? 'J ' : 'M ') + fmtMag(mag) + ' · ' + PALETTES[b.palette || 0].name;
row.addEventListener('click', () => {
restoreSnapshot(b);
toast('Restored “' + b.name + '”');
});
const del = document.createElement('button');
del.className = 'bm-del';
del.textContent = '✕';
del.title = 'Delete';
del.addEventListener('click', e => {
e.stopPropagation();
const l2 = loadBookmarks();
l2.splice(idx, 1);
persistBookmarks(l2);
renderBmList();
});
row.appendChild(del);
list.appendChild(row);
});
}
/* Shareable link: #m=<mode>&re=..&im=..&scale=..&p=..&it=..&cRe=..&cIm=.. */
let hashTimer = 0;
function updateHashSoon() {
clearTimeout(hashTimer);
hashTimer = setTimeout(() => {
const s = viewSnapshot();
try {
history.replaceState(null, '', '#' + new URLSearchParams({
m: s.mode, re: String(s.re), im: String(s.im), scale: String(s.scale),
p: String(s.palette), it: String(s.iterMult),
cRe: String(s.cRe), cIm: String(s.cIm),
}).toString());
} catch (err) { /* file:// may block history API — non-fatal */ }
}, 350);
}
function readHash() {
const h = location.hash.slice(1);
if (!h) return null;
const q = new URLSearchParams(h);
if (!q.has('re') || !q.has('scale')) return null;
return {
mode: q.get('m') === 'j' ? 'j' : 'm',
re: +q.get('re'), im: +q.get('im'), scale: +q.get('scale'),
palette: clamp(parseInt(q.get('p') || '0', 10) || 0, 0, PALETTES.length - 1),
iterMult: clamp(+q.get('it') || 1, 0.5, 8),
offset: 0,
cRe: +q.get('cRe') || 0, cIm: +q.get('cIm') || 0,
};
}
/* ================================================================
SECTION 15 — OVERLAY (zoom box, reticle, Julia crosshair)
================================================================ */
function drawOverlay() {
octx.clearRect(0, 0, W, H);
if (drag.active && drag.mode === 'rect' && drag.moved) {
const x = Math.min(drag.sx, drag.lx), y = Math.min(drag.sy, drag.ly);
const w = Math.abs(drag.lx - drag.sx), h = Math.abs(drag.ly - drag.sy);
octx.fillStyle = 'rgba(255,255,255,0.07)';
octx.fillRect(x, y, w, h);
octx.lineWidth = 2;
octx.strokeStyle = '#fff';
octx.strokeRect(x + 1, y + 1, w - 2, h - 2);
octx.lineWidth = 1;
octx.setLineDash([4, 4]);
octx.strokeStyle = 'rgba(255,255,255,0.45)';
octx.beginPath();
octx.moveTo(x + w / 2, y); octx.lineTo(x + w / 2, y + h);
octx.moveTo(x, y + h / 2); octx.lineTo(x + w, y + h / 2);
octx.stroke();
octx.setLineDash([]);
}
if (state.active === 'j') {
const cx = W / 2, cy = H / 2, r = 26, t = performance.now() / 1000;
octx.strokeStyle = 'rgba(255,255,255,0.85)';
octx.lineWidth = 1.4;
for (let i = 0; i < 4; i++) {
const a = t * 0.6 + i * Math.PI / 2;
octx.beginPath();
octx.arc(cx, cy, r, a, a + 1.1);
octx.stroke();
}
octx.beginPath(); octx.arc(cx, cy, 3, 0, Math.PI * 2);
octx.fillStyle = '#fff'; octx.fill();
} else if (hover.on && !drag.active) {
const r = 12;
octx.strokeStyle = 'rgba(255,255,255,0.8)';
octx.lineWidth = 1.2;
octx.beginPath(); octx.arc(hover.x, hover.y, r, 0, Math.PI * 2); octx.stroke();
octx.beginPath();
octx.moveTo(hover.x - r - 5, hover.y); octx.lineTo(hover.x - r + 3, hover.y);
octx.moveTo(hover.x + r - 3, hover.y); octx.lineTo(hover.x + r + 5, hover.y);
octx.moveTo(hover.x, hover.y - r - 5); octx.lineTo(hover.x, hover.y - r + 3);
octx.moveTo(hover.x, hover.y + r - 3); octx.lineTo(hover.x, hover.y + r + 5);
octx.stroke();
}
}
/* ================================================================
SECTION 16 — MAIN LOOP
================================================================ */
let lastFrame = performance.now(), fpsEMA = 60;
function frame(now) {
const dt = Math.min(0.1, (now - lastFrame) / 1000);
lastFrame = now;
fpsEMA = fpsEMA * 0.92 + (1 / Math.max(1e-3, dt)) * 0.08;
hudFps.textContent = Math.round(fpsEMA);
if (state.anim) {
state.offset = (state.offset + CFG.ANIM_SPEED * dt) | 0;
if (state.offset >= CFG.LUT_SIZE) state.offset -= CFG.LUT_SIZE;
el('sl-offset').value = state.offset;
recolorAll();
if (state.active === 'm' && hover.on) schedulePreview();
}
if (momentum.active) {
const v = activeView();
const dx = momentum.vx * dt * 1000, dy = momentum.vy * dt * 1000;
if (Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) {
momentum.active = false;
} else {
panBy(dx, dy);
momentum.vx *= Math.exp(-dt / CFG.MOMENTUM_DECAY);
momentum.vy *= Math.exp(-dt / CFG.MOMENTUM_DECAY);
}
}
drawOverlay();
requestAnimationFrame(frame);
}
/* ================================================================
SECTION 17 — DEBUG INTERFACE + INIT
================================================================ */
/* Debug: iterate z0 = re + i*im (absolute complex coords) for the
active set (Mandelbrot: c = z0; Julia: constant c) up to maxIter
(default = current view limit). Returns the iteration count;
reaching the cap ⇒ interior. */
function iterationsAt(re, im, maxIter) {
const mi = maxIter || currentMaxIter();
const julia = state.active === 'j';
let zr = re, zi = im;
const cr = julia ? state.j.cRe : re, ci = julia ? state.j.cIm : im;
let zr2 = zr * zr, zi2 = zi * zi, n = 0;
while (n < mi && zr2 + zi2 <= 4.0) {
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr; zi2 = zi * zi;
n++;
}
return n;
}
function init() {
buildLUT(state.palette);
buildChips();
wireUI();
initInput();
initWorkers();
resize(); // sizes canvases, restores hash state, kicks render
wake();
requestAnimationFrame(frame);
}
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, CFG.DPR_CAP);
Wcss = Math.max(1, Math.round(window.innerWidth));
Hcss = Math.max(1, Math.round(window.innerHeight));
W = Math.round(Wcss * DPR);
H = Math.round(Hcss * DPR);
canvas.width = W; canvas.height = H;
canvas.style.width = Wcss + 'px'; canvas.style.height = Hcss + 'px';
ocanvas.width = W; ocanvas.height = H;
ocanvas.style.width = Wcss + 'px'; ocanvas.style.height = Hcss + 'px';
const snap = readHash();
if (snap) restoreSnapshot(snap);
else changeView(null);
}
window.addEventListener('resize', () => {
const oldW = Wcss;
if (oldW > 0) {
for (const key of ['m', 'j']) {
const s = state[key];
s.width = (s.width / oldW) * Wcss;
}
}
resize();
});
window.fractal = {
iterationsAt,
setView: setViewPub,
state: () => JSON.parse(JSON.stringify({
mode: state.active, re: state[state.active].re, im: state[state.active].im,
scale: state[state.active].width / Wcss, mag: viewMag(),
maxIter: currentMaxIter(), palette: state.palette,
iterMult: state.iterMult, offset: state.offset,
cRe: state.j.cRe, cIm: state.j.cIm,
})),
palette, toggleMode, backHistory, resetView,
saveBookmark, restoreSnapshot, viewSnapshot,
restoreFromHash: (h) => {
if (h !== undefined) location.hash = h;
const s = readHash();
if (s) { restoreSnapshot(s); return true; }
return false;
},
CFG,
};
init();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"3e9c1752-7d3d-4241-b875-74006305d667","tokensIn":7473489,"tokensOut":143712,"tokensTotal":7617201,"cost":0,"turns":68,"toolCalls":73,"failedToolCalls":0,"timestamp":"2026-09-01T19:57:58.796Z"} -->