Fractal Explorer
Deepseek v4.1 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 74,134 bytes · SHA-256 6d050879dfed
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
/* ============================================================
SECTION: STYLES — layout reset, HUD chrome, overlays
============================================================ */
:root {
--hud-bg: rgba(12, 14, 22, 0.55);
--hud-bg-solid: rgba(12, 14, 22, 0.82);
--hud-border: rgba(255, 255, 255, 0.14);
--hud-text: rgba(238, 242, 255, 0.92);
--hud-dim: rgba(200, 208, 232, 0.62);
--accent: #6ee7ff;
--accent-2: #b48cff;
--radius: 10px;
--font: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
background: #05060a; overflow: hidden;
font-family: var(--font);
color: var(--hud-text);
-webkit-font-smoothing: antialiased;
touch-action: none;
}
#stage { position: fixed; inset: 0; }
canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
display: block;
cursor: crosshair;
image-rendering: auto;
}
#glCanvas { z-index: 1; }
#fxCanvas { z-index: 2; pointer-events: none; }
/* ---------- HUD ---------- */
#hud {
position: fixed; z-index: 10;
top: 14px; left: 14px; right: 14px;
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px; pointer-events: none;
transition: opacity .45s ease, transform .45s ease;
}
#hud.idle { opacity: 0; transform: translateY(-6px); }
.panel {
pointer-events: auto;
background: var(--hud-bg);
border: 1px solid var(--hud-border);
border-radius: var(--radius);
backdrop-filter: blur(14px) saturate(150%);
-webkit-backdrop-filter: blur(14px) saturate(150%);
box-shadow: 0 8px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
padding: 10px 12px;
}
.stat-grid {
display: grid; grid-template-columns: auto auto; gap: 3px 14px;
font-size: 11.5px; line-height: 1.5;
}
.stat-grid .k { color: var(--hud-dim); letter-spacing: .04em; text-transform: uppercase; font-size: 9.5px; align-self: center;}
.stat-grid .v { color: var(--hud-text); font-variant-numeric: tabular-nums; text-align: right; }
.stat-grid .v.accent { color: var(--accent); }
.stack { display: flex; flex-direction: column; gap: 10px; align-items: flex-end; }
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
button, .chip {
pointer-events: auto;
font-family: var(--font);
font-size: 11px;
letter-spacing: .03em;
color: var(--hud-text);
background: rgba(255,255,255,.055);
border: 1px solid var(--hud-border);
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
transition: background .15s ease, border-color .15s ease, transform .1s ease, color .15s;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
button:hover, .chip:hover { background: rgba(255,255,255,.12); border-color: rgba(255,255,255,.3); }
button:active { transform: translateY(1px) scale(.985); }
button:focus-visible, input:focus-visible, .chip:focus-visible {
outline: 2px solid var(--accent); outline-offset: 1px;
}
button.primary { background: rgba(110,231,255,.16); border-color: rgba(110,231,255,.45); color: #d9f6ff; }
button.primary:hover { background: rgba(110,231,255,.28); }
button.on { background: rgba(180,140,255,.26); border-color: rgba(180,140,255,.6); color: #f0e9ff; }
.ctl { display: flex; align-items: center; gap: 8px; font-size: 10px; color: var(--hud-dim); }
.ctl label { text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; min-width: 62px; }
input[type=range] {
-webkit-appearance: none; appearance: none;
width: 122px; height: 18px; background: transparent; cursor: pointer; margin: 0;
}
input[type=range]::-webkit-slider-runnable-track {
height: 3px; border-radius: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
opacity: .75;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 12px; height: 12px; margin-top: -4.5px;
border-radius: 50%; background: #eaf6ff; border: 1px solid rgba(0,0,0,.4);
box-shadow: 0 0 8px rgba(110,231,255,.7);
}
input[type=range]::-moz-range-track { height: 3px; border-radius: 3px; background: rgba(255,255,255,.25); }
input[type=range]::-moz-range-thumb { width: 12px; height: 12px; border: none; border-radius: 50%; background: #eaf6ff; }
input[type=range].wide { width: 150px; }
/* palette swatches */
#palettes { display: flex; gap: 6px; }
.swatch {
width: 42px; height: 32px; border-radius: 8px; cursor: pointer;
border: 1px solid rgba(255,255,255,.18);
position: relative; overflow: hidden;
box-shadow: inset 0 0 0 1px rgba(0,0,0,.35);
transition: transform .12s ease, box-shadow .15s ease;
}
.swatch:hover { transform: translateY(-2px); }
.swatch.sel { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(110,231,255,.7); transform: translateY(-2px); }
.swatch span {
position: absolute; bottom: 1px; left: 0; right: 0; text-align: center;
font-size: 8px; letter-spacing: .05em; color: rgba(255,255,255,.95);
text-shadow: 0 1px 3px rgba(0,0,0,.95); text-transform: uppercase;
}
#juliaPanel .title { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--hud-dim); margin-bottom: 6px; }
#juliaCanvasWrap { position: relative; width: 168px; height: 168px; border-radius: 8px; overflow: hidden; border: 1px solid var(--hud-border); background: #000; }
#juliaCanvas { position: absolute; inset: 0; width: 100%; height: 100%; cursor: crosshair; }
#juliaPanel input[type=text] {
width: 168px; margin-top: 6px; background: rgba(0,0,0,.4); border: 1px solid var(--hud-border);
color: var(--hud-text); border-radius: 6px; padding: 4px 6px; font-family: var(--font); font-size: 11px;
}
#juliaPanel.hidden { display: none; }
/* ---------- bottom bar ---------- */
#bottom {
position: fixed; z-index: 10; left: 50%; bottom: 14px; transform: translateX(-50%);
display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center;
max-width: calc(100% - 20px);
transition: opacity .45s ease, transform .45s ease;
}
#bottom.idle { opacity: 0; transform: translateX(-50%) translateY(8px); pointer-events: none; }
/* ---------- bookmarks dropdown ---------- */
#bookmarks {
position: fixed; z-index: 11; top: 14px; right: 14px;
width: 268px; max-height: 62vh; overflow: auto;
display: none; flex-direction: column; gap: 4px;
}
#bookmarks.show { display: flex; }
#bookmarks .bm {
display: flex; align-items: center; gap: 6px; justify-content: space-between;
padding: 5px 7px; border-radius: 7px; background: rgba(255,255,255,.045); font-size: 11px;
}
#bookmarks .bm .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#bookmarks .bm .meta { font-size: 9px; color: var(--hud-dim); }
#bookmarks .bm button { padding: 3px 7px; font-size: 10px; }
#bookmarks h3 { margin: 2px 0 6px; font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--hud-dim); font-weight: 500;}
#bookmarks .empty { font-size: 11px; color: var(--hud-dim); padding: 4px 2px;}
#bmName {
background: rgba(0,0,0,.4); border: 1px solid var(--hud-border); border-radius: 7px;
color: var(--hud-text); font-family: var(--font); font-size: 11px; padding: 5px 7px; width: 100%;
}
/* ---------- help / toast ---------- */
#help {
position: fixed; z-index: 12; inset: 0; display: none;
align-items: center; justify-content: center; background: rgba(4,5,10,.72);
backdrop-filter: blur(6px);
}
#help.show { display: flex; }
#help .card { max-width: 430px; padding: 22px 24px; }
#help h2 { margin: 0 0 10px; font-size: 15px; letter-spacing: .04em; }
#help table { border-collapse: collapse; font-size: 12px; width: 100%; }
#help td { padding: 3px 0; color: var(--hud-dim); }
#help td:first-child { color: var(--hud-text); white-space: nowrap; padding-right: 14px; }
#help kbd {
font-family: var(--font); font-size: 10.5px; background: rgba(255,255,255,.1);
border: 1px solid var(--hud-border); border-radius: 4px; padding: 1px 5px; color: var(--hud-text);
}
#toast {
position: fixed; z-index: 14; left: 50%; bottom: 68px; transform: translateX(-50%) translateY(10px);
background: var(--hud-bg-solid); border: 1px solid var(--hud-border); border-radius: 9px;
padding: 8px 14px; font-size: 11.5px; opacity: 0; transition: opacity .3s, transform .3s;
pointer-events: none; max-width: 80vw; text-align: center;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#progress {
position: fixed; z-index: 9; left: 0; top: 0; height: 2px; width: 0%;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
transition: width .18s linear; box-shadow: 0 0 8px var(--accent);
}
@media (max-width: 760px) {
#hud { flex-direction: column; }
.stack { align-items: flex-start; }
.row { justify-content: flex-start; }
#bookmarks { width: calc(100% - 28px); }
#juliaCanvasWrap { width: 120px; height: 120px; }
#juliaPanel input[type=text] { width: 120px; }
}
</style>
</head>
<body>
<div id="progress"></div>
<div id="stage">
<canvas id="glCanvas"></canvas>
<canvas id="fxCanvas"></canvas>
</div>
<!-- ============ HUD ============ -->
<div id="hud">
<div class="panel" id="statPanel">
<div class="stat-grid">
<div class="k">Zoom</div><div class="v accent" id="sZoom">×1.00</div>
<div class="k">Center</div><div class="v" id="sCenter">—</div>
<div class="k">Iter</div><div class="v" id="sIter">—</div>
<div class="k">Render</div><div class="v" id="sTime">—</div>
<div class="k">FPS</div><div class="v" id="sFps">—</div>
<div class="k">Mode</div><div class="v" id="sMode">Mandelbrot</div>
</div>
</div>
<div class="stack">
<div class="panel">
<div class="ctl" style="margin-bottom:8px">
<label>Palette</label>
<div id="palettes"></div>
</div>
<div class="ctl"><label title="Extra iterations added on top of the zoom-scaled base">Iter boost</label><input type="range" id="iterBoost" min="0" max="4000" step="10" value="0"></div>
<div class="ctl"><label title="Shifts palette phase without recomputing iterations">Cycle</label><input type="range" id="cycle" min="0" max="100" step="1" value="0"></div>
<div class="ctl"><label title="Mandelbrot set: complex power exponent (2.00 = classic)">Power</label><input type="range" id="power" min="20" max="600" step="1" value="100"></div>
<div class="ctl"><label title="Nonlinearity of palette spread">Spread</label><input type="range" id="spread" min="10" max="400" step="1" value="100"></div>
</div>
<div class="panel" id="juliaPanel">
<div class="title">Julia preview · <span id="cVal">c = 0 + 0i</span></div>
<div id="juliaCanvasWrap"><canvas id="juliaCanvas" width="220" height="220"></canvas></div>
<input type="text" id="cInput" spellcheck="false" aria-label="Julia c value">
<div class="row" style="margin-top:6px">
<button id="openJulia" class="primary" title="Swap the main view to this Julia set (J)">Open Julia in main</button>
</div>
</div>
</div>
</div>
<div id="bottom">
<button id="back" title="Zoom out one step (Backspace / Z)">◀ Back</button>
<button id="reset" title="Reset to full Mandelbrot view (R)">Reset</button>
<button id="mandel" title="Switch main view to Mandelbrot (M)">Mandelbrot</button>
<button id="julia" title="Show Julia panel (J)">Julia…</button>
<button id="bmBtn" title="Bookmark views">★ Bookmarks</button>
<button id="share" title="Copy a shareable link">⧉ Link</button>
<button id="helpBtn" title="Keyboard shortcuts (?)">?</button>
</div>
<div id="bookmarks" class="panel">
<h3>Bookmarks</h3>
<div style="display:flex;gap:6px">
<input id="bmName" placeholder="name this view…" spellcheck="false">
<button id="bmSave" class="primary">Save</button>
</div>
<div id="bmList" style="display:flex;flex-direction:column;gap:4px;margin-top:8px"></div>
</div>
<div id="help">
<div class="card panel">
<h2>Fractal Explorer</h2>
<table>
<tr><td><kbd>Drag</kbd></td><td>Rectangle zoom (release to apply)</td></tr>
<tr><td><kbd>Wheel</kbd></td><td>Zoom toward cursor</td></tr>
<tr><td><kbd>Right-drag</kbd> / <kbd>Shift</kbd>+drag</td><td>Pan (also two-finger)</td></tr>
<tr><td><kbd>Click</kbd></td><td>Julia preview at that point</td></tr>
<tr><td><kbd>Arrows</kbd></td><td>Pan · <kbd>+</kbd>/<kbd>−</kbd> zoom</td></tr>
<tr><td><kbd>J</kbd> / <kbd>M</kbd></td><td>Julia / Mandelbrot main view</td></tr>
<tr><td><kbd>R</kbd> / <kbd>Bksp</kbd></td><td>Reset / back</td></tr>
<tr><td><kbd>P</kbd></td><td>Cycle palette</td></tr>
<tr><td><kbd>[</kbd> <kbd>]</kbd></td><td>Palette cycle offset</td></tr>
<tr><td><kbd>S</kbd> / <kbd>?</kbd></td><td>Save bookmark / this help</td></tr>
</table>
<div class="row" style="margin-top:14px">
<button id="helpClose" class="primary">Got it</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
"use strict";
/* ============================================================================
FRACTAL EXPLORER
Single-file, dependency-free Mandelbrot & Julia explorer.
Sections
A. Tunable constants block
B. Color palettes & colour math
C. State + precision model
D. Core escape-time loop (allocation-free)
E. Renderer: progressive multi-pass, cancellation, workers
F. Canvas presentation for main view + Julia inset
G. Interaction: drag-zoom, pan, wheel, keyboard, touch
H. HUD, bookmarks (localStorage), URL hash
I. Debug interface on window.fractal
========================================================================== */
/* ============================ A. CONSTANTS ============================== */
const CFG = {
/* Iteration limits: maxIter = BASE + SCALE * log10(zoom) (clamped). */
ITER_BASE: 100,
ITER_SCALE: 185,
ITER_MIN: 64,
ITER_MAX: 12000,
ITER_BOOST_MAX: 4000, // manual override slider range
/* Hard precision ceiling. Doubles lose useful mantissa bits past ~1e13. */
MAX_ZOOM: 1e13,
WARN_ZOOM: 1e12,
/* Navigation */
WHEEL_RATE: 0.0018, // exp zoom per wheel delta unit
WHEEL_LINE: 1.0,
ZOOM_STEP: 1.6, // keyboard +/- step
PAN_FRACTION: 0.12, // arrow-key pan (fraction of view width)
/* Progressive render passes: [scaleDivisor (px block), iterationFraction].
Pass 0 is fast and blocky; later passes refine to 1:1. */
PASSES: [
{ block: 8, iterFrac: 0.32 },
{ block: 4, iterFrac: 0.55 },
{ block: 1, iterFrac: 1.00 },
],
/* Workers */
USE_WORKERS: true,
WORKER_POOL: Math.max(1, Math.min(8, (navigator.hardwareConcurrency || 4) - 1)),
/* Julia inset */
JULIA_ITER: 260,
JULIA_SIZE: 220,
/* Interaction */
DRAG_THRESHOLD: 6, // px before a drag becomes a rectangle zoom
HUD_IDLE_MS: 2600,
MOMENTUM: 0.90,
MOMENTUM_MIN: 0.02,
/* Display */
ZOOM_FOR_FULL_VIEW: 0.7, // view span for the default framing
};
const DEFAULT_CENTER = { re: -0.6, im: 0.0 };
const DEFAULT_SCALE = 3.2; // vertical span of the view in complex units
/* ============================ B. PALETTES =============================== */
/* Each palette: array of stops [t, r, g, b] with t in [0,1], plus cyclic flag.
Interpolation is done in a perceptual-ish way: linear on a gamma-adjusted
square root, which avoids muddy midpoints. */
function mkPalette(stops, cyclic) { return { stops, cyclic: !!cyclic }; }
const PALETTES = {
Fire: mkPalette([
[0.00, 0, 0, 0],
[0.14, 36, 0, 62],
[0.34, 128, 8, 70],
[0.52, 214, 44, 28],
[0.70, 252, 132, 16],
[0.86, 255, 214, 92],
[1.00, 255, 255, 224],
], false),
Ocean: mkPalette([
[0.00, 1, 4, 14],
[0.16, 6, 32, 70],
[0.38, 8, 86, 128],
[0.58, 18, 158, 166],
[0.76, 108, 226, 214],
[0.90, 208, 250, 246],
[1.00, 255, 255, 255],
], false),
Monochrome: mkPalette([
[0.00, 0, 0, 0],
[0.22, 22, 22, 26],
[0.48, 92, 92, 100],
[0.70, 172, 172, 182],
[0.88, 232, 232, 240],
[1.00, 255, 255, 255],
], false),
Psychedelic: mkPalette([
[0.000, 20, 0, 48],
[0.166, 120, 30, 200],
[0.333, 240, 40, 160],
[0.500, 255, 190, 40],
[0.666, 60, 230, 130],
[0.833, 30, 130, 240],
[1.000, 20, 0, 48],
], true),
Aurora: mkPalette([
[0.00, 3, 6, 18],
[0.18, 12, 54, 74],
[0.40, 22, 140, 128],
[0.58, 120, 232, 170],
[0.74, 228, 252, 214],
[0.88, 214, 168, 255],
[1.00, 90, 60, 170],
], false),
Ember: mkPalette([
[0.00, 0, 0, 0],
[0.20, 46, 10, 6],
[0.42, 130, 26, 10],
[0.62, 214, 80, 14],
[0.80, 250, 170, 60],
[0.93, 255, 236, 190],
[1.00, 120, 60, 180],
], false),
};
const PALETTE_NAMES = Object.keys(PALETTES);
const PALETTE_SHORT = {
Fire: "Fire", Ocean: "Ocean", Monochrome: "Mono",
Psychedelic: "Psych", Aurora: "Aurora", Ember: "Ember",
};
/* Palette gradients are baked into a Uint32Array LUT (little-endian ABGR,
which is exactly what an ImageData/ArrayBuffer byte view expects), so the
colorize pass is a pure table lookup — no float color math per pixel. */
const LUT_SIZE = 1024;
const PAL_LUT = {}; // name -> Uint32Array(LUT_SIZE)
function buildPalettes() {
for (const name of PALETTE_NAMES) {
const p = PALETTES[name];
const lut = new Uint32Array(LUT_SIZE);
const s = p.stops;
for (let i = 0; i < LUT_SIZE; i++) {
const t = i / (LUT_SIZE - 1);
// find bracketing stops
let k = 0;
while (k < s.length - 2 && s[k + 1][0] < t) k++;
const a = s[k], b = s[k + 1];
const span = (b[0] - a[0]) || 1;
let f = (t - a[0]) / span;
if (f < 0) f = 0; else if (f > 1) f = 1;
f = f * f * (3 - 2 * f); // smoothstep between stops
const r = (a[1] + (b[1] - a[1]) * f) | 0;
const g = (a[2] + (b[2] - a[2]) * f) | 0;
const bl = (a[3] + (b[3] - a[3]) * f) | 0;
lut[i] = (255 << 24) | (bl << 16) | (g << 8) | r;
}
PAL_LUT[name] = lut;
}
}
buildPalettes();
/* COLOR: mapping constants (colorize pass only — never triggers re-iterating) */
const COLOR = {
SQRT_RATE: 0.085, // cycles per sqrt(iteration)
SPREAD_DEF: 1.0,
};
/* ============================ C. STATE ================================= */
/* Precision model
---------------
All coordinates are IEEE-754 doubles. The reachable resolution is
ulp(|c|) ~ 2^-52 ~ 2.2e-16. With the default vertical span of 3.2 the
*minimum* usable span is therefore ~ (3.2 * 1e-13) before neighbouring
pixels start collapsing onto identical doubles — i.e. about ×1e13
magnification. MAX_ZOOM enforces exactly that; WARN_ZOOM warns earlier. */
const PRECISION_DECADES = 13;
CFG.MAX_ZOOM = Math.pow(10, PRECISION_DECADES);
CFG.WARN_ZOOM = Math.pow(10, PRECISION_DECADES - 1);
const state = {
mode: "mandelbrot", // "mandelbrot" | "julia"
center: { re: DEFAULT_CENTER.re, im: DEFAULT_CENTER.im },
scale: DEFAULT_SCALE, // vertical span, complex units
jc: { re: -0.7885, im: 0.156 }, // Julia parameter
palette: "Fire",
iterBoost: 0,
cycle: 0, // palette phase offset in cycles
spread: 1.0,
power: 2.0,
smooth: true,
autoIter: true,
};
const view = { // exact view currently ON SCREEN
mode: "mandelbrot",
cx: DEFAULT_CENTER.re, cy: DEFAULT_CENTER.im,
span: DEFAULT_SCALE,
jcRe: 0, jcIm: 0,
maxIter: CFG.ITER_BASE,
W: 0, H: 0, dpr: 1,
};
const history = []; // zoom history stack (committed views)
/* Canvas / buffer bookkeeping ------------------------------------------- */
const glCanvas = document.getElementById("glCanvas");
const fxCanvas = document.getElementById("fxCanvas");
const gctx = glCanvas.getContext("2d", { alpha: false, desynchronized: true });
const fctx = fxCanvas.getContext("2d");
let buf = null; // { W, H, img: ImageData, u32: Uint32Array, esc: Float32Array }
let pending = null; // candidate view while interacting (not yet rendered)
/* ---------------------- geometry helpers ------------------------------- */
function zoomOf(s) { return DEFAULT_SCALE / s; }
function viewW() { return view.span * view.W / view.H; }
/* Screen <-> complex mapping.
Screen coordinates are CONTINUOUS device-pixel coordinates in [0,W]x[0,H]:
the geometric centre of the canvas is (W/2, H/2) and the centre of pixel
index i is at i+0.5. The renderer samples pixel i at (i+0.5) with the same
affine constants, so a selection rectangle frames exactly what it covers. */
function pixelToComplex(px, py, W, H, cx, cy, span) {
const vw = span * W / H, vh = span;
return {
re: cx + (px / W - 0.5) * vw,
im: cy + (0.5 - py / H) * vh,
};
}
function complexToPixel(re, im, W, H, cx, cy, span) {
const vw = span * W / H, vh = span;
return {
x: ((re - cx) / vw + 0.5) * W,
y: (0.5 - (im - cy) / vh) * H,
};
}
/* Iteration limit scales with zoom depth; manual slider adds a boost. */
function iterationsFor(span) {
const z = Math.max(1, zoomOf(span));
let n = CFG.ITER_BASE + CFG.ITER_SCALE * Math.log10(z) + state.iterBoost;
if (n < CFG.ITER_MIN) n = CFG.ITER_MIN;
if (n > CFG.ITER_MAX) n = CFG.ITER_MAX;
return Math.round(n);
}
/* ===================== D. CORE ITERATION LOOP ========================== */
/* Allocation-free escape-time integration. Everything below writes into
caller-supplied buffers; no object / array literals are created inside the
per-pixel hot path. `block` samples one point per block×block tile and
replicates it, so low-resolution passes cost block^2 times less fill-in
while still covering the whole canvas (no holes, no ghost tiles). */
const BAIL2 = 4.0; // |z| > 2 <=> |z|^2 > 4
const LOG2 = Math.log(2);
let ES_LAST_N = 0; // integer escape iteration of the last escapePoint call
/* Single-point escape with smooth (continuous) iteration count.
Returns the float iteration value, or -1 for "interior" (never escaped). */
function escapePoint(cRe, cIm, maxIter, julia, jRe, jIm, pow2, usePow) {
let zr, zi, cr, ci;
if (julia) { zr = cRe; zi = cIm; cr = jRe; ci = jIm; }
else { zr = 0; zi = 0; cr = cRe; ci = cIm; }
let m = 0, n = 0;
if (!usePow) {
for (; n < maxIter; n++) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > BAIL2) break;
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
}
if (n >= maxIter) { ES_LAST_N = n; return -1; }
m = zr * zr + zi * zi;
} else {
const p = state.power, half = p * 0.5;
for (; n < maxIter; n++) {
const r2 = zr * zr + zi * zi;
if (r2 > BAIL2) break;
if (r2 < 1e-300) { zr = cr; zi = ci; continue; }
const r = Math.sqrt(r2);
const th = Math.atan2(zi, zr);
const nr = Math.pow(r, p);
const na = half * th;
zr = nr * Math.cos(na) + cr;
zi = nr * Math.sin(na) + ci;
}
if (n >= maxIter) { ES_LAST_N = n; return -1; }
m = zr * zr + zi * zi;
}
ES_LAST_N = n;
if (m <= BAIL2) m = BAIL2 + 1e-9;
// smooth: mu = n + 1 - log2(log|z|)
const lg = 0.5 * Math.log(m) / LOG2; // log2|z|
let mu = n + 1 - Math.log(lg > 1e-12 ? lg : 1e-12) / LOG2;
if (!(mu >= 0)) mu = n;
return mu;
}
/* Build a render-parameter snapshot (copied once per pass, not per pixel). */
function makeParams(W, H, cx, cy, span, maxIter, mode, jcRe, jcIm) {
return {
W, H, cx, cy, span, maxIter,
julia: mode === "julia" ? 1 : 0,
jcRe, jcIm,
usePow: state.power !== 2.0,
// precomputed affine terms: re = aRe + px*stepRe ; im = aIm - py*stepIm
stepRe: span / H,
stepIm: span / H,
aRe: cx - (span * W / H) * 0.5,
aIm: cy + span * 0.5,
};
}
/* Render rows [y0, y1) of a pass into out (length (y1-y0)*W).
`out` may be the destination subarray of the full escape buffer. */
function renderRows(out, outOff, p, y0, y1, block) {
const W = p.W, span = p.span;
const blockF = block;
const maxIter = p.maxIter;
const julia = p.julia, jcRe = p.jcRe, jcIm = p.jcIm;
const usePow = p.usePow, power = state.power;
const half = power * 0.5;
for (let y = y0; y < y1; y += block) {
// sample at the block centre for a better low-res approximation
const im = p.aIm - (y + blockF * 0.5) * p.stepIm;
const rowBase = (y - y0) * W;
for (let x = 0; x < W; x += block) {
const re = p.aRe + (x + blockF * 0.5) * p.stepRe;
// ---- inline escape loop (duplicated for speed; no per-pixel alloc) ----
let zr, zi, cr, ci;
if (julia) { zr = re; zi = im; cr = jcRe; ci = jcIm; }
else { zr = 0; zi = 0; cr = re; ci = im; }
let n = 0, m = 0;
if (!usePow) {
for (; n < maxIter; n++) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > BAIL2) break;
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
}
} else {
for (; n < maxIter; n++) {
const r2 = zr * zr + zi * zi;
if (r2 > BAIL2) break;
if (r2 < 1e-300) { zr = cr; zi = ci; continue; }
const r = Math.sqrt(r2), th = Math.atan2(zi, zr);
const nr = Math.pow(r, power), na = half * th;
zr = nr * Math.cos(na) + cr;
zi = nr * Math.sin(na) + ci;
}
}
let val;
if (n >= maxIter) {
val = -1;
} else {
m = zr * zr + zi * zi;
if (m <= BAIL2) m = BAIL2 + 1e-9;
const lg = 0.5 * Math.log(m) / LOG2;
val = n + 1 - Math.log(lg > 1e-12 ? lg : 1e-12) / LOG2;
if (!(val >= 0)) val = n;
}
// fill the block×block tile
const xEnd = Math.min(x + block, W);
const yEnd = Math.min(y + block, y1);
for (let yy = y; yy < yEnd; yy++) {
const base = (yy - y0) * W;
for (let xx = x; xx < xEnd; xx++) out[base + xx] = val;
}
}
}
}
/* Colorize: escape floats -> pixels. Pure pass; changing palette / cycle /
spread re-runs only this, never the iteration loop. */
function colorize(esc, W, H, u32, lut, cyclic, smooth, cycle, spread, maxIter) {
const rate = COLOR.SQRT_RATE;
const n = esc.length;
if (!smooth) {
/* quantized fallback: one flat colour per integer iteration (debug/compare) */
for (let i = 0; i < n; i++) {
const e = esc[i];
if (e < 0) { u32[i] = 0xff000000; continue; }
let u = (e | 0) * 0.031 + cycle;
u -= Math.floor(u);
u32[i] = lut[(u * (LUT_SIZE - 1)) | 0];
}
return;
}
for (let i = 0; i < n; i++) {
const e = esc[i];
if (e < 0) { u32[i] = 0xff000000; continue; }
// f = sqrt(e) * rate + cycle (sqrt keeps band width usable at any depth)
const f = Math.sqrt(e) * rate + cycle;
let u;
if (cyclic) {
u = f - Math.floor(f); // wrap (palette loops)
} else {
// smooth triangle: 0 -> 1 -> 0 across each cycle. Continuous everywhere,
// so there is never a hard edge at a band boundary.
const ph = f - Math.floor(f);
u = 0.5 - 0.5 * Math.cos(ph * 6.283185307179586);
}
if (spread !== 1) u = Math.pow(u, spread);
let idx = (u * (LUT_SIZE - 1)) | 0;
if (idx < 0) idx = 0; else if (idx > LUT_SIZE - 1) idx = LUT_SIZE - 1;
u32[i] = lut[idx];
}
}
/* ======================= E. RENDERER / SCHEDULER ======================= */
/* Progressive, cancellable, time-sliced renderer.
A "job" walks CFG.PASSES from coarse to fine. Each pass is split into
horizontal bands; bands are either handed to Web Workers or computed inline
under a per-frame time budget. Every pass fills the *whole* canvas (coarse
blocks are replicated), so at no point are there holes or ghost tiles.
`token` is the cancellation generation: bumping it abandons every in-flight
band result without touching the framebuffer. */
const WORKER_SRC = `
"use strict";
const BAIL2 = 4.0, LOG2 = Math.log(2);
let P = null, power = 2.0, usePow = false;
function renderBand(y0, y1, block) {
const W = P.W, maxIter = P.maxIter;
const julia = P.julia, jcRe = P.jcRe, jcIm = P.jcIm;
const half = power * 0.5;
const rows = y1 - y0;
const out = new Float32Array(rows * W);
for (let y = y0; y < y1; y += block) {
const im = P.aIm - (y + block * 0.5) * P.stepIm;
for (let x = 0; x < W; x += block) {
const re = P.aRe + (x + block * 0.5) * P.stepRe;
let zr, zi, cr, ci;
if (julia) { zr = re; zi = im; cr = jcRe; ci = jcIm; }
else { zr = 0; zi = 0; cr = re; ci = im; }
let n = 0, m = 0;
if (!usePow) {
for (; n < maxIter; n++) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > BAIL2) break;
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
}
} else {
for (; n < maxIter; n++) {
const r2 = zr * zr + zi * zi;
if (r2 > BAIL2) break;
if (r2 < 1e-300) { zr = cr; zi = ci; continue; }
const r = Math.sqrt(r2), th = Math.atan2(zi, zr);
const nr = Math.pow(r, power), na = half * th;
zr = nr * Math.cos(na) + cr;
zi = nr * Math.sin(na) + ci;
}
}
let val;
if (n >= maxIter) { val = -1; }
else {
m = zr * zr + zi * zi;
if (m <= BAIL2) m = BAIL2 + 1e-9;
const lg = 0.5 * Math.log(m) / LOG2;
val = n + 1 - Math.log(lg > 1e-12 ? lg : 1e-12) / LOG2;
if (!(val >= 0)) val = n;
}
const xEnd = Math.min(x + block, W), yEnd = Math.min(y + block, y1);
for (let yy = y; yy < yEnd; yy++) {
const base = (yy - y0) * W;
for (let xx = x; xx < xEnd; xx++) out[base + xx] = val;
}
}
}
return out;
}
self.onmessage = function (ev) {
const d = ev.data;
if (d.cmd === "cfg") { P = d.p; power = d.power; usePow = d.usePow; return; }
P = d.p; power = d.power; usePow = d.usePow;
const band = renderBand(d.y0, d.y1, d.block);
self.postMessage({ id: d.id, token: d.token, y0: d.y0, y1: d.y1, block: d.block, buf: band },
[band.buffer]);
};
`;
let workers = [];
function initWorkers() {
if (!CFG.USE_WORKERS || typeof Worker === "undefined") return;
try {
const url = URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" }));
for (let i = 0; i < CFG.WORKER_POOL; i++) {
const w = new Worker(url);
w.busy = false;
w.onmessage = onWorkerMessage;
w.onerror = (e) => {
// blob workers are blocked on some file:// origins — fall back inline
if (e && e.preventDefault) e.preventDefault();
w.dead = true;
if (!FORCE_INLINE) fallbackInline();
};
workers.push(w);
}
} catch (e) {
workers = [];
}
}
let FORCE_INLINE = false;
initWorkers();
/* Called if the worker pool turns out to be unusable: abandon the pool and
re-run the pass on the main thread. No partial state is left behind. */
function fallbackInline() {
FORCE_INLINE = true;
for (const w of workers) { w.dead = true; try { w.terminate(); } catch (e) {} }
Renderer.inflight = 0;
if (Renderer.active) beginPass(Renderer.passIdx);
}
const Renderer = {
token: 0,
active: false,
esc: null, // Float32Array(W*H) escape values, -1 = interior
W: 0, H: 0,
params: null,
passIdx: 0,
queue: [], // pending band [y0,y1] for current pass
inflight: 0,
doneBands: 0,
totalBands: 0,
t0: 0,
lastMs: 0,
presented: 0,
};
function allocEsc(W, H) {
if (!buf || buf.W !== W || buf.H !== H) {
const img = gctx.createImageData(W, H);
buf = { W, H, img, u32: new Uint32Array(img.data.buffer), esc: new Float32Array(W * H) };
}
Renderer.esc = buf.esc;
Renderer.W = W; Renderer.H = H;
}
/* --- schedule a fresh render for the current `state` view ------------- */
function scheduleRender() {
if (!buf) return; // not sized yet; resize() will call us
const W = buf.W, H = buf.H;
Renderer.token++;
Renderer.passIdx = 0;
Renderer.presented = 0;
passesDone = 0;
Renderer.t0 = performance.now();
Renderer.lastProgress = Renderer.t0;
Renderer.active = true;
const maxIterFull = iterationsFor(state.scale);
view.mode = state.mode;
view.cx = state.center.re; view.cy = state.center.im;
view.span = state.scale;
view.jcRe = state.jc.re; view.jcIm = state.jc.im;
view.maxIter = maxIterFull;
beginPass(0);
}
function beginPass(idx) {
const P = CFG.PASSES[idx];
Renderer.passIdx = idx;
const H = Renderer.H;
const maxIter = Math.max(CFG.ITER_MIN,
Math.round(iterationsFor(state.scale) * P.iterFrac));
const p = makeParams(Renderer.W, H, state.center.re, state.center.im, state.scale,
maxIter, state.mode, state.jc.re, state.jc.im);
Renderer.params = p;
// fan the pass out into bands
const block = P.block;
const useWorkers = !FORCE_INLINE && workers.length > 0 && !workers.every(w => w.dead);
const bandH = useWorkers
? Math.max(block * 2, Math.ceil(H / (workers.length * 3) / block) * block)
: Math.max(block * 4, 24);
Renderer.queue = [];
for (let y = 0; y < H; y += bandH) {
Renderer.queue.push([y, Math.min(y + bandH, H)]);
}
Renderer.inflight = 0;
Renderer.doneBands = 0;
Renderer.totalBands = Renderer.queue.length;
pumpRender();
}
function onWorkerMessage(ev) {
const d = ev.data;
const w = ev.target;
if (w) { w.busy = false; w._seq = -1; } // free the worker
if (d.token !== Renderer.token) { pumpRender(); return; } // stale: discard, keep going
const W = Renderer.W;
if (Renderer.esc && Renderer.esc.length >= W * Renderer.H) {
Renderer.esc.set(d.buf, d.y0 * W);
Renderer.inflight--;
Renderer.doneBands++;
Renderer.lastProgress = performance.now();
afterBand();
}
}
function afterBand() {
if (Renderer.inflight > 0 || Renderer.queue.length > 0) { pumpRender(); return; }
if (Renderer.doneBands >= Renderer.totalBands) presentPass();
}
function pumpRender() {
if (!Renderer.active) return;
const useWorkers = !FORCE_INLINE && workers.length > 0 && !workers.every(w => w.dead);
if (useWorkers) {
for (const w of workers) {
if (Renderer.queue.length === 0) break;
if (w.busy || w.dead) continue;
const band = Renderer.queue.shift();
const id = (Renderer.bandSeq = (Renderer.bandSeq || 0) + 1);
w._seq = id;
w.busy = true;
Renderer.inflight++;
w.postMessage({
id, token: Renderer.token, y0: band[0], y1: band[1],
block: CFG.PASSES[Renderer.passIdx].block,
p: Renderer.params, power: state.power, usePow: state.power !== 2.0,
});
}
if (Renderer.queue.length === 0 && Renderer.inflight === 0) {
presentPass();
}
return;
}
// ---- inline, time-sliced ----
const deadline = performance.now() + 13;
const block = CFG.PASSES[Renderer.passIdx].block;
while (Renderer.queue.length) {
const band = Renderer.queue.shift();
renderRows(Renderer.esc, band[0] * Renderer.W, Renderer.params, band[0], band[1], block);
Renderer.doneBands++;
if (performance.now() > deadline) break;
}
if (Renderer.queue.length === 0) presentPass();
}
function presentPass() {
if (!Renderer.active) return;
colorize(Renderer.esc, Renderer.W, Renderer.H, buf.u32,
PAL_LUT[state.palette], PALETTES[state.palette].cyclic,
state.smooth, state.cycle, state.spread, view.maxIter);
gctx.putImageData(buf.img, 0, 0);
Renderer.lastMs = performance.now() - Renderer.t0;
Renderer.presented++;
onPresented();
const next = Renderer.passIdx + 1;
if (next < CFG.PASSES.length) {
beginPass(next);
} else {
Renderer.active = false;
setProgress(1, true);
}
}
/* Colour-only refresh: re-colorize the existing escape buffer, no iteration. */
function recolorize() {
if (!Renderer.esc || !Renderer.W) return;
colorize(Renderer.esc, Renderer.W, Renderer.H, buf.u32,
PAL_LUT[state.palette], PALETTES[state.palette].cyclic,
state.smooth, state.cycle, state.spread, view.maxIter);
gctx.putImageData(buf.img, 0, 0);
}
/* ==================== F. PRESENTATION / OVERLAYS ======================= */
let fps = 0, fpsAcc = 0, fpsFrames = 0, fpsT = performance.now();
let passesDone = 0;
function setProgress(frac, hide) {
const el = document.getElementById("progress");
el.style.width = (frac * 100).toFixed(1) + "%";
if (hide) setTimeout(() => { el.style.opacity = "0"; }, 220);
else el.style.opacity = "1";
}
function onPresented() {
passesDone++;
setProgress(passesDone / CFG.PASSES.length, passesDone >= CFG.PASSES.length);
updateStats();
}
/* --- resize: keep the complex view fixed, just re-rasterise ---------- */
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const cssW = Math.max(1, glCanvas.clientWidth || window.innerWidth);
const cssH = Math.max(1, glCanvas.clientHeight || window.innerHeight);
const W = Math.max(1, Math.round(cssW * dpr));
const H = Math.max(1, Math.round(cssH * dpr));
glCanvas.width = W; glCanvas.height = H;
fxCanvas.width = W; fxCanvas.height = H;
allocEsc(W, H);
view.W = W; view.H = H; view.dpr = dpr;
// rebase so the complex-space framing is preserved across aspect changes
scheduleRender();
}
/* ------------------------- Julia inset pane --------------------------- */
const juliaCanvas = document.getElementById("juliaCanvas");
const jctx = juliaCanvas.getContext("2d", { alpha: false });
const JB = {
W: CFG.JULIA_SIZE, H: CFG.JULIA_SIZE,
img: jctx.createImageData(CFG.JULIA_SIZE, CFG.JULIA_SIZE),
u32: null, esc: new Float32Array(CFG.JULIA_SIZE * CFG.JULIA_SIZE),
};
JB.u32 = new Uint32Array(JB.img.data.buffer);
let juliaStage = 0, juliaStageC = null;
function juliaSpan(re, im) {
const r = Math.hypot(re, im);
return 3.3 * Math.max(0.85, Math.min(2.6, 0.55 + 0.95 * r));
}
function juliaParams(re, im, W) {
return makeParams(JB.W, JB.H, 0, 0, juliaSpan(re, im),
Math.round(CFG.JULIA_ITER * (W || 1)), "julia", re, im);
}
/* Two-stage inset draw: coarse this frame, full-res next frame. */
function requestJulia(re, im) {
juliaStageC = { re, im };
if (juliaStage === 0) {
drawJuliaStage(3);
juliaStage = 1;
requestAnimationFrame(juliaNextStage);
}
}
function juliaNextStage() {
if (!juliaStageC) { juliaStage = 0; return; }
if (juliaStage >= 1) {
drawJuliaStage(1);
juliaStage = 2;
requestAnimationFrame(() => { juliaStage = 0; if (juliaStageC) juliaNextStage(); });
} else {
juliaStage = 0;
}
}
function drawJuliaStage(block) {
if (!juliaStageC) return;
const { re, im } = juliaStageC;
const p = juliaParams(re, im, block > 1 ? 0.6 : 1);
renderRows(JB.esc, 0, p, 0, JB.H, block);
colorize(JB.esc, JB.W, JB.H, JB.u32, PAL_LUT[state.palette],
PALETTES[state.palette].cyclic, state.smooth, state.cycle, state.spread, p.maxIter);
jctx.putImageData(JB.img, 0, 0);
// mark the current c inside the inset
jctx.save();
jctx.strokeStyle = "rgba(255,255,255,.9)"; jctx.lineWidth = 1;
jctx.beginPath(); jctx.moveTo(0, JB.H / 2); jctx.lineTo(JB.W, JB.H / 2);
jctx.moveTo(JB.W / 2, 0); jctx.lineTo(JB.W / 2, JB.H);
jctx.stroke();
jctx.restore();
}
/* --------------------- interaction overlay (fx) ----------------------- */
const OV = { mode: 0, x0: 0, y0: 0, x1: 0, y1: 0, reticle: false, rx: 0, ry: 0 };
function drawOverlay() {
fctx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
const dpr = view.dpr;
if (OV.mode === 1) {
const x = Math.min(OV.x0, OV.x1), y = Math.min(OV.y0, OV.y1);
const w = Math.abs(OV.x1 - OV.x0), h = Math.abs(OV.y1 - OV.y0);
// OV coordinates are already in device pixels
fctx.save();
fctx.globalCompositeOperation = "difference";
fctx.strokeStyle = "rgba(255,255,255,.95)";
fctx.lineWidth = 1.5 * dpr;
fctx.strokeRect(x, y, w, h);
fctx.restore();
fctx.save();
fctx.fillStyle = "rgba(110,231,255,.10)";
fctx.fillRect(x, y, w, h);
fctx.strokeStyle = "rgba(110,231,255,.85)";
fctx.lineWidth = 1 * dpr;
fctx.setLineDash([4 * dpr, 4 * dpr]);
fctx.strokeRect(x, y, w, h);
fctx.restore();
// magnification the selection would produce
const zoom = DEFAULT_SCALE / (view.span * (h / fxCanvas.height));
const label = fmtZoom(zoom) + ` ${(w / dpr).toFixed(0)}×${(h / dpr).toFixed(0)}`;
fctx.save();
fctx.font = `${11 * dpr}px ui-monospace, monospace`;
const tw = fctx.measureText(label).width;
const lx = x + w / 2 - tw / 2, ly = y + h + 16 * dpr;
fctx.fillStyle = "rgba(6,8,14,.72)";
fctx.fillRect(lx - 6 * dpr, ly - 12 * dpr, tw + 12 * dpr, 17 * dpr);
fctx.fillStyle = "rgba(230,245,255,.98)";
fctx.fillText(label, lx, ly);
fctx.restore();
} else if (OV.mode === 2) { // pan indicator
fctx.save();
fctx.globalCompositeOperation = "difference";
fctx.fillStyle = "rgba(255,255,255,.9)";
fctx.beginPath(); fctx.arc(OV.x1, OV.y1, 5 * dpr, 0, 6.2832); fctx.fill();
fctx.restore();
}
if (OV.reticle && state.mode === "mandelbrot") {
fctx.save();
fctx.globalCompositeOperation = "difference";
fctx.strokeStyle = "rgba(255,255,255,.75)";
fctx.lineWidth = 1 * dpr;
const x = OV.rx, y = OV.ry, r = 9 * dpr;
fctx.beginPath();
fctx.moveTo(x - r, y); fctx.lineTo(x - 3 * dpr, y);
fctx.moveTo(x + 3 * dpr, y); fctx.lineTo(x + r, y);
fctx.moveTo(x, y - r); fctx.lineTo(x, y - 3 * dpr);
fctx.moveTo(x, y + 3 * dpr); fctx.lineTo(x, y + r);
fctx.stroke();
fctx.restore();
}
}
function fmtZoom(z) {
if (z < 1000) return z.toFixed(z < 10 ? 2 : 1) + "×";
const e = Math.floor(Math.log10(z));
const m = z / Math.pow(10, e);
return m.toFixed(2) + "e" + e;
}
function fmtCoord(v, span) {
const d = Math.max(2, Math.min(16, Math.ceil(-Math.log10(span)) + 3));
return v.toFixed(d);
}
/* ======================== G. INTERACTION =============================== */
const ptrs = new Map();
let drag = null; // { kind:'zoom'|'pan'|'pinch', ... }
let momentum = { vx: 0, vy: 0, active: false };
let lastPointerCss = { x: 0, y: 0 };
function cssToCanvas(e) {
const r = glCanvas.getBoundingClientRect();
const d = view.dpr || 1;
// return DEVICE pixels so every handler shares the renderer's coordinate space
return { x: (e.clientX - r.left) * d, y: (e.clientY - r.top) * d };
}
function clampSpan(s) {
const min = DEFAULT_SCALE / CFG.MAX_ZOOM;
const max = DEFAULT_SCALE * 4;
return Math.max(min, Math.min(max, s));
}
function showToast(msg, ms) {
const t = document.getElementById("toast");
t.textContent = msg;
t.classList.add("show");
clearTimeout(t._t);
t._t = setTimeout(() => t.classList.remove("show"), ms || 2000);
}
let hudTimer = null, hudIdle = false;
function wakeHud() {
const hud = document.getElementById("hud"), bot = document.getElementById("bottom");
if (hudIdle) { hud.classList.remove("idle"); bot.classList.remove("idle"); hudIdle = false; }
clearTimeout(hudTimer);
hudTimer = setTimeout(() => {
hud.classList.add("idle"); bot.classList.add("idle"); hudIdle = true;
}, CFG.HUD_IDLE_MS);
}
/* ---- commit a view change: push history, reschedule ---- */
let depthWarned = 0;
function commitView(nextCenter, nextSpan, opts) {
opts = opts || {};
if (!opts.noHistory) {
history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: view.mode,
jcRe: view.jcRe, jcIm: view.jcIm });
if (history.length > 200) history.shift();
}
state.center.re = nextCenter.re; state.center.im = nextCenter.im;
const want = nextSpan;
state.scale = clampSpan(want);
// Precision guard: double precision bottoms out around ×1e13 of magnification.
const z = zoomOf(state.scale);
if (state.scale > want * (1 + 1e-9)) {
if (depthWarned !== 2) {
depthWarned = 2;
showToast("Max depth reached — double precision is exhausted at ×1e13", 3200);
}
} else if (z >= CFG.WARN_ZOOM && depthWarned === 0) {
depthWarned = 1;
showToast("Approaching the double-precision limit (×1e12) — fine detail will pixelate", 3200);
} else if (z < CFG.WARN_ZOOM * 0.5) {
depthWarned = 0;
}
updateStats();
scheduleRender();
}
/* Apply a rectangle selection (canvas pixels) — the region framed is the
region rendered: the rect is adjusted to the canvas aspect ratio around its
own centre, so nothing silently shifts. */
function zoomToRect(x0, y0, x1, y1, noHistory) {
const W = view.W, H = view.H;
let rx = Math.min(x0, x1), ry = Math.min(y0, y1);
let rw = Math.abs(x1 - x0), rh = Math.abs(y1 - y0);
if (rw < 4 || rh < 4) return false;
const targetAspect = W / H;
// expand the smaller dimension symmetrically so the selection stays centred
if (rw / rh > targetAspect) { const nh = rw / targetAspect; ry -= (nh - rh) / 2; rh = nh; }
else { const nw = rh * targetAspect; rx -= (nw - rw) / 2; rw = nw; }
const cxPix = rx + rw / 2, cyPix = ry + rh / 2;
// map using the *same* affine model as the renderer
const c = pixelToComplex(cxPix, cyPix, W, H, view.cx, view.cy, view.span);
const newSpan = view.span * (rh / H);
lastRect = { x: rx, y: ry, w: rw, h: rh };
commitView({ re: c.re, im: c.im }, newSpan, { noHistory });
return { cx: c.re, cy: c.im, span: newSpan, rect: lastRect };
}
let lastRect = null;
function zoomAt(px, py, factor) {
const W = view.W, H = view.H;
const p = pixelToComplex(px, py, W, H, view.cx, view.cy, view.span);
const newSpan = clampSpan(view.span * factor);
const k = newSpan / view.span;
commitView({ re: p.re + (view.cx - p.re) * k, im: p.im + (view.cy - p.im) * k }, newSpan);
}
function panByPixels(dxPix, dyPix) {
const W = view.W, H = view.H;
const vw = view.span * W / H, vh = view.span;
commitView({ re: view.cx - dxPix / W * vw, im: view.cy + dyPix / H * vh }, view.span,
{ noHistory: true });
}
/* ---------------------------- pointer events -------------------------- */
glCanvas.addEventListener("pointerdown", (e) => {
wakeHud();
glCanvas.setPointerCapture(e.pointerId);
ptrs.set(e.pointerId, cssToCanvas(e));
const c = cssToCanvas(e);
const isPan = e.button === 2 || e.shiftKey || e.altKey || e.metaKey;
if (ptrs.size === 2) {
const [a, b] = [...ptrs.values()];
drag = { kind: "pinch", d: Math.hypot(a.x - b.x, a.y - b.y), span: view.span, c: view.center };
// anchor at the pinch midpoint
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
drag.anchor = pixelToComplex(mx, my, view.W, view.H, view.cx, view.cy, view.span);
OV.mode = 0;
} else if (isPan) {
drag = { kind: "pan", x: c.x, y: c.y, moved: 0 };
OV.mode = 2; OV.x1 = c.x; OV.y1 = c.y;
} else {
drag = { kind: "zoom", x0: c.x, y0: c.y, x1: c.x, y1: c.y, moved: 0 };
}
momentum.active = false;
}, { passive: true });
glCanvas.addEventListener("pointermove", (e) => {
wakeHud();
const c = cssToCanvas(e);
lastPointerCss = c;
const prev = ptrs.get(e.pointerId);
ptrs.set(e.pointerId, c);
if (drag && drag.kind === "pinch" && ptrs.size >= 2) {
const [a, b] = [...ptrs.values()];
const d = Math.hypot(a.x - b.x, a.y - b.y);
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
const f = Math.max(1e-4, drag.d / Math.max(1, d));
const newSpan = clampSpan(drag.span * f);
const k = newSpan / drag.span;
const an = drag.anchor;
// keep the pinch anchor pinned and follow the midpoint drift
const cur = pixelToComplex(mx, my, view.W, view.H, view.cx, view.cy, view.span);
commitView({ re: an.re + (cur.re - an.re) * k, im: an.im + (cur.im - an.im) * k },
newSpan, { noHistory: true });
return;
}
if (drag && drag.kind === "pan") {
const dx = c.x - drag.x, dy = c.y - drag.y;
drag.x = c.x; drag.y = c.y; drag.moved += Math.abs(dx) + Math.abs(dy);
const before = { cx: view.cx, cy: view.cy };
panByPixels(dx, dy);
momentum.vx = view.cx - before.cx; momentum.vy = view.cy - before.cy;
OV.x1 = c.x; OV.y1 = c.y;
drawOverlay();
return;
}
if (drag && drag.kind === "zoom") {
drag.moved += Math.abs(c.x - drag.x1) + Math.abs(c.y - drag.y1);
drag.x1 = c.x; drag.y1 = c.y;
if (drag.moved > CFG.DRAG_THRESHOLD * (view.dpr || 1)) {
OV.mode = 1;
const rect = aspectRect(drag.x0, drag.y0, drag.x1, drag.y1);
OV.x0 = rect.x; OV.y0 = rect.y; OV.x1 = rect.x + rect.w; OV.y1 = rect.y + rect.h;
drawOverlay();
}
return;
}
if (prev) {
// idle hover: reticle + live Julia preview
OV.reticle = true; OV.rx = c.x; OV.ry = c.y;
if (state.mode === "mandelbrot" && !idleHoverBlocked) {
const p = pixelToComplex(c.x, c.y, view.W, view.H, view.cx, view.cy, view.span);
requestJulia(p.re, p.im);
setJuliaEditor(p.re, p.im, true);
}
drawOverlay();
}
}, { passive: true });
glCanvas.addEventListener("pointerup", (e) => {
const c = cssToCanvas(e);
ptrs.delete(e.pointerId);
if (drag && drag.kind === "zoom") {
if (drag.moved > CFG.DRAG_THRESHOLD) {
const rect = aspectRect(drag.x0, drag.y0, c.x, c.y);
OV.mode = 0; drawOverlay();
zoomToRect(rect.x, rect.y, rect.x + rect.w, rect.y + rect.h);
showToast("Zoomed into selection");
} else {
// plain click: choose the Julia parameter
const p = pixelToComplex(c.x, c.y, view.W, view.H, view.cx, view.cy, view.span);
setJuliaParam(p.re, p.im, true);
showToast("Julia preview set — press J to open it in the main view");
}
} else if (drag && drag.kind === "pan") {
OV.mode = 0; drawOverlay();
momentum.active = true;
}
drag = null;
}, { passive: true });
glCanvas.addEventListener("pointercancel", () => { ptrs.clear(); drag = null; OV.mode = 0; drawOverlay(); });
glCanvas.addEventListener("pointerleave", () => {
OV.reticle = false; drawOverlay();
});
glCanvas.addEventListener("contextmenu", (e) => e.preventDefault());
/* rectangle selection constrains its own aspect ratio to the canvas so the
drawn box is exactly the framed region */
function aspectRect(x0, y0, x1, y1) {
const A = view.W / view.H;
let dx = x1 - x0, dy = y1 - y0;
let w = Math.abs(dx), h = Math.abs(dy);
if (w / Math.max(h, 1e-6) > A) h = w / A; else w = h * A;
return { x: dx < 0 ? x0 - w : x0, y: dy < 0 ? y0 - h : y0, w, h };
}
/* ------------------------------- wheel -------------------------------- */
glCanvas.addEventListener("wheel", (e) => {
e.preventDefault();
wakeHud();
const c = cssToCanvas(e);
if (ptrs.size >= 2) return; // pinch owns the gesture
let delta = e.deltaY;
if (e.deltaMode === 1) delta *= 16;
const factor = Math.exp(delta * CFG.WHEEL_RATE);
zoomAt(c.x, c.y, factor);
OV.reticle = true; OV.rx = c.x; OV.ry = c.y;
if (state.mode === "mandelbrot") {
const p = pixelToComplex(c.x, c.y, view.W, view.H, view.cx, view.cy, view.span);
requestJulia(p.re, p.im);
setJuliaEditor(p.re, p.im, true);
}
}, { passive: false });
/* ------------------------------ keyboard ------------------------------- */
window.addEventListener("keydown", (e) => {
if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
wakeHud();
const k = e.key;
const pan = CFG.PAN_FRACTION * view.span;
const vw = viewW();
let handled = true;
switch (k) {
case "ArrowLeft": commitView({ re: view.cx - vw * CFG.PAN_FRACTION, im: view.cy }, view.span); break;
case "ArrowRight": commitView({ re: view.cx + vw * CFG.PAN_FRACTION, im: view.cy }, view.span); break;
case "ArrowUp": commitView({ re: view.cx, im: view.cy + pan }, view.span); break;
case "ArrowDown": commitView({ re: view.cx, im: view.cy - pan }, view.span); break;
case "+": case "=": zoomAt(view.W / 2, view.H / 2, 1 / CFG.ZOOM_STEP); break;
case "-": case "_": zoomAt(view.W / 2, view.H / 2, CFG.ZOOM_STEP); break;
case "r": case "R": resetView(); break;
case "Backspace": case "z": case "Z": goBack(); break;
case "p": case "P": cyclePalette(1); break;
case "[": state.cycle -= 0.04; applyRecolor(); break;
case "]": state.cycle += 0.04; applyRecolor(); break;
case "j": case "J": openJuliaInMain(); break;
case "m": case "M": goMandelbrot(); break;
case "s": case "S": saveBookmark(null); break;
case "?": case "/": toggleHelp(); break;
case "h": case "H": toggleHud(); break;
default: handled = false;
}
if (handled) e.preventDefault();
});
/* ================ H. HUD / PALETTES / BOOKMARKS / HASH ================= */
const $ = (id) => document.getElementById(id);
function lutCss(name) {
const lut = PAL_LUT[name];
const pts = [];
for (let i = 0; i <= 8; i++) {
const v = lut[Math.round(i / 8 * (LUT_SIZE - 1))];
const r = v & 255, g = (v >> 8) & 255, b = (v >> 16) & 255;
pts.push(`rgb(${r},${g},${b}) ${(i / 8 * 100).toFixed(0)}%`);
}
return `linear-gradient(90deg, ${pts.join(",")})`;
}
function buildPaletteUI() {
const wrap = $("palettes");
wrap.innerHTML = "";
PALETTE_NAMES.forEach((name, i) => {
const d = document.createElement("div");
d.className = "swatch" + (name === state.palette ? " sel" : "");
d.style.background = lutCss(name);
d.title = name + " (key: number row / P cycles)";
d.tabIndex = 0;
d.setAttribute("role", "button");
d.dataset.name = name;
d.innerHTML = `<span>${(PALETTE_SHORT[name] || name).toUpperCase()}</span>`;
const pick = () => { state.palette = name; syncPaletteUI(); applyRecolor(); };
d.addEventListener("click", pick);
d.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(); } });
wrap.appendChild(d);
});
}
function syncPaletteUI() {
document.querySelectorAll(".swatch").forEach((d) => {
d.classList.toggle("sel", d.dataset.name === state.palette);
});
}
function cyclePalette(dir) {
const i = PALETTE_NAMES.indexOf(state.palette);
state.palette = PALETTE_NAMES[(i + dir + PALETTE_NAMES.length) % PALETTE_NAMES.length];
syncPaletteUI(); applyRecolor();
}
function applyRecolor() {
recolorize();
if (juliaStageC) drawJuliaStage(1);
updateStats();
}
/* ------------------------------ stats ---------------------------------- */
function updateStats() {
const z = zoomOf(view.span);
$("sZoom").textContent = fmtZoom(z);
$("sCenter").textContent = `${fmtCoord(view.cx, view.span)}, ${fmtCoord(view.cy, view.span)}i`;
$("sIter").textContent = view.maxIter + (state.power !== 2 ? ` · p=${state.power.toFixed(1)}` : "");
$("sTime").textContent = Renderer.lastMs ? Renderer.lastMs.toFixed(0) + " ms" : "—";
$("sMode").textContent = view.mode === "julia"
? `Julia c=${state.jc.re.toFixed(3)}${state.jc.im >= 0 ? "+" : ""}${state.jc.im.toFixed(3)}i`
: "Mandelbrot";
$("sFps").textContent = fps ? fps.toFixed(0) : "—";
$("sFps").style.color = fps && fps < 24 ? "#ff9d7a" : "";
}
/* --------------------------- julia plumbing ---------------------------- */
let idleHoverBlocked = false;
function setJuliaParam(re, im, open) {
state.jc.re = re; state.jc.im = im;
requestJulia(re, im);
setJuliaEditor(re, im, false);
if (open) {
$("juliaPanel").classList.remove("hidden");
$("cVal").textContent = `c = ${re.toFixed(4)} ${im >= 0 ? "+" : "−"} ${Math.abs(im).toFixed(4)}i`;
}
updateStats();
}
function setJuliaEditor(re, im, live) {
const el = $("cInput");
const txt = `${re.toFixed(6)}, ${im.toFixed(6)}`;
if (live) {
if (document.activeElement === el) return;
el.value = txt;
$("cVal").textContent = `c = ${re.toFixed(4)} ${im >= 0 ? "+" : "−"} ${Math.abs(im).toFixed(4)}i`;
} else if (document.activeElement !== el) {
el.value = txt;
}
}
$("cInput").addEventListener("input", () => {
const m = $("cInput").value.split(/[,\s]+/).map(Number).filter((v) => !isNaN(v));
if (m.length >= 2) { setJuliaParam(m[0], m[1], true); }
});
$("cInput").addEventListener("focus", () => { idleHoverBlocked = true; });
$("cInput").addEventListener("blur", () => { idleHoverBlocked = false; });
function openJuliaInMain() {
if (state.mode === "mandelbrot") {
state.mode = "julia";
$("julia").classList.add("on");
showToast(`Julia set for c = ${state.jc.re.toFixed(4)} ${state.jc.im >= 0 ? "+" : "−"} ${Math.abs(state.jc.im).toFixed(4)}i`);
} else {
showToast("Already in Julia mode");
return;
}
state.center.re = 0; state.center.im = 0;
state.scale = juliaSpan(state.jc.re, state.jc.im);
history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: "mandelbrot", jcRe: view.jcRe, jcIm: view.jcIm });
updateStats(); scheduleRender();
}
function goMandelbrot() {
state.mode = "mandelbrot";
$("julia").classList.remove("on");
state.center.re = DEFAULT_CENTER.re; state.center.im = DEFAULT_CENTER.im;
state.scale = DEFAULT_SCALE;
history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: "julia", jcRe: view.jcRe, jcIm: view.jcIm });
updateStats(); scheduleRender();
}
function resetView() {
state.mode = "mandelbrot";
$("julia").classList.remove("on");
state.center.re = DEFAULT_CENTER.re; state.center.im = DEFAULT_CENTER.im;
state.scale = DEFAULT_SCALE;
history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: view.mode, jcRe: view.jcRe, jcIm: view.jcIm });
updateStats(); scheduleRender();
showToast("Reset to the full set");
}
function goBack() {
if (!history.length) { showToast("Nothing to go back to"); return; }
const h = history.pop();
state.mode = h.mode === "julia" ? "julia" : "mandelbrot";
$("julia").classList.toggle("on", state.mode === "julia");
state.center.re = h.cx; state.center.im = h.cy; state.scale = h.span;
if (state.mode === "julia") { state.jc.re = h.jcRe; state.jc.im = h.jcIm; }
updateStats(); scheduleRender();
}
/* ---------------------------- bookmarks -------------------------------- */
const BM_KEY = "fractalExplorer.bookmarks.v1";
function loadBM() {
try { return JSON.parse(localStorage.getItem(BM_KEY)) || []; } catch (e) { return []; }
}
function writeBM(list) {
try { localStorage.setItem(BM_KEY, JSON.stringify(list)); } catch (e) {}
}
function snapshotState() {
return {
v: 1, mode: state.mode,
cx: state.center.re, cy: state.center.im, span: state.scale,
jcRe: state.jc.re, jcIm: state.jc.im,
palette: state.palette, iterBoost: state.iterBoost, cycle: state.cycle,
spread: state.spread, power: state.power, smooth: state.smooth,
};
}
function restoreSnapshot(s, pushHist) {
if (!s) return false;
if (pushHist !== false) {
history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: view.mode,
jcRe: view.jcRe, jcIm: view.jcIm });
}
state.mode = s.mode === "julia" ? "julia" : "mandelbrot";
state.center.re = +s.cx; state.center.im = +s.cy; state.scale = clampSpan(+s.span);
if (s.jcRe !== undefined) { state.jc.re = +s.jcRe; state.jc.im = +s.jcIm; }
if (s.palette && PAL_LUT[s.palette]) state.palette = s.palette;
state.iterBoost = +s.iterBoost || 0;
state.cycle = +s.cycle || 0;
state.spread = s.spread === undefined ? 1 : +s.spread;
state.power = +s.power || 2;
state.smooth = s.smooth !== false;
syncControls();
$("julia").classList.toggle("on", state.mode === "julia");
setJuliaParam(state.jc.re, state.jc.im, true);
updateStats();
scheduleRender();
return true;
}
function syncControls() {
$("iterBoost").value = state.iterBoost;
$("cycle").value = Math.round(state.cycle * 100);
$("spread").value = Math.round(state.spread * 100);
$("power").value = Math.round(state.power * 50);
syncPaletteUI();
}
function saveBookmark(name) {
const list = loadBM();
const nm = (name || $("bmName").value || "").trim() || ("View ×" + fmtZoom(zoomOf(view.span)));
list.unshift({ name: nm, ts: Date.now(), data: snapshotState() });
writeBM(list.slice(0, 60));
$("bmName").value = "";
renderBM();
showToast(`Saved “${nm}”`);
}
function renderBM() {
const list = loadBM();
const wrap = $("bmList");
wrap.innerHTML = "";
if (!list.length) {
wrap.innerHTML = `<div class="empty">No bookmarks yet — frame a view, name it, hit Save (or press S).</div>`;
return;
}
list.forEach((bm, i) => {
const d = document.createElement("div");
d.className = "bm";
const z = zoomOf(bm.data.span || DEFAULT_SCALE);
d.innerHTML = `<div class="nm" title="${bm.name}"><div>${bm.name}</div>
<div class="meta">${bm.data.mode === "julia" ? "Julia" : "Mandel"} · ${fmtZoom(z)} · ${bm.data.palette}</div></div>`;
const go = document.createElement("button");
go.textContent = "Load";
go.onclick = () => { if (restoreSnapshot(bm.data)) showToast(`Restored “${bm.name}”`); };
const del = document.createElement("button");
del.textContent = "✕";
del.title = "Delete";
del.onclick = () => {
const l = loadBM(); l.splice(i, 1); writeBM(l); renderBM();
};
d.appendChild(go); d.appendChild(del);
wrap.appendChild(d);
});
}
/* ------------------------------ URL hash ------------------------------- */
let hashTimer = null, lastHash = "";
function encodeHash() {
const s = snapshotState();
try { return "#v=" + btoa(JSON.stringify(s)).replace(/=+$/, ""); } catch (e) { return ""; }
}
function decodeHash() {
const h = location.hash.replace(/^#/, "");
if (!h) return null;
try {
if (h.startsWith("v=")) {
const b = h.slice(2).replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(atob(b));
}
} catch (e) {}
return null;
}
function pushHash() {
const h = encodeHash();
if (!h || h === lastHash) return; // avoid pointless history churn
lastHash = h;
try { history0.replaceState(null, "", h); } catch (e) { location.hash = h; }
}
const history0 = window.history;
/* ------------------------------ help / hud ----------------------------- */
function toggleHelp() { $("help").classList.toggle("show"); }
function toggleHud() {
hudIdle = !hudIdle;
$("hud").classList.toggle("idle", hudIdle);
$("bottom").classList.toggle("idle", hudIdle);
}
function shareLink() {
pushHash();
const url = location.href;
const done = () => showToast("Link copied — share the exact view");
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(done, () => showToast(url, 4000));
} else showToast(url, 4000);
}
/* ===================== I. WIRING + MAIN LOOP =========================== */
$("back").onclick = goBack;
$("reset").onclick = resetView;
$("mandel").onclick = goMandelbrot;
$("julia").onclick = openJuliaInMain;
$("bmBtn").onclick = () => { $("bookmarks").classList.toggle("show"); renderBM(); };
$("bmSave").onclick = () => saveBookmark(null);
$("share").onclick = shareLink;
$("helpBtn").onclick = toggleHelp;
$("helpClose").onclick = () => $("help").classList.remove("show");
$("openJulia").onclick = openJuliaInMain;
$("bmName").addEventListener("keydown", (e) => { if (e.key === "Enter") saveBookmark(null); });
$("iterBoost").addEventListener("input", (e) => {
state.iterBoost = +e.target.value; updateStats(); scheduleRender();
});
$("cycle").addEventListener("input", (e) => {
state.cycle = +e.target.value / 100; applyRecolor(); // colour-only pass
});
$("spread").addEventListener("input", (e) => {
state.spread = +e.target.value / 100; applyRecolor();
});
$("power").addEventListener("input", (e) => {
state.power = +e.target.value / 50; updateStats(); scheduleRender();
});
window.addEventListener("resize", () => { clearTimeout(window._rt); window._rt = setTimeout(resize, 120); });
document.addEventListener("click", (e) => {
const bm = $("bookmarks");
if (bm.classList.contains("show") && !bm.contains(e.target) && e.target.id !== "bmBtn") {
bm.classList.remove("show");
}
});
window.addEventListener("hashchange", () => {
if (location.hash === lastHash) return; // our own write, nothing to do
const s = decodeHash();
if (s && s.cx !== undefined) { lastHash = location.hash; restoreSnapshot(s, false); }
});
/* main loop: FPS, momentum panning, overlay refresh */
let overlayDirty = false;
function frame(now) {
requestAnimationFrame(frame);
fpsFrames++;
if (now - fpsT > 400) {
fps = fpsFrames * 1000 / (now - fpsT);
fpsT = now; fpsFrames = 0;
$("sFps").textContent = fps.toFixed(0);
}
if (momentum.active) {
const mag = Math.hypot(momentum.vx, momentum.vy);
if (mag < view.span * CFG.MOMENTUM_MIN) momentum.active = false;
else {
commitView({ re: view.cx + momentum.vx * 9, im: view.cy + momentum.vy * 9 },
view.span, { noHistory: true });
momentum.vx *= CFG.MOMENTUM; momentum.vy *= CFG.MOMENTUM;
}
}
// watchdog: if the worker pool silently dies, fall back to the main thread
if (Renderer.active && Renderer.inflight > 0 && !FORCE_INLINE &&
now - (Renderer.lastProgress || 0) > 1500) {
fallbackInline();
}
if (state.mode === "mandelbrot" && OV.reticle) {
// live-update the reticle coordinate readout in the julia label
overlayDirty = !overlayDirty;
}
}
requestAnimationFrame(frame);
/* ---------------------------- boot ------------------------------------ */
function boot() {
buildPaletteUI();
renderBM();
state.center.re = DEFAULT_CENTER.re; state.center.im = DEFAULT_CENTER.im;
state.scale = DEFAULT_SCALE;
syncControls();
const hs = decodeHash();
if (hs) restoreSnapshot(hs, false); else { updateStats(); }
resize();
setJuliaParam(state.jc.re, state.jc.im, true);
wakeHud();
// periodic hash sync (debounced) so links always reflect the live view
setInterval(() => {
if (document.activeElement && document.activeElement.tagName === "INPUT") return;
clearTimeout(hashTimer);
hashTimer = setTimeout(pushHash, 200);
}, 900);
}
if (document.readyState === "complete" || document.readyState === "interactive") setTimeout(boot, 0);
else window.addEventListener("DOMContentLoaded", boot);
/* ============== J. HEADLESS / DEBUG INTERFACE (window.fractal) ========== */
/* Everything a test needs to reason about the math and the view, without
touching the DOM or the render scheduler. */
window.fractal = {
CFG, state, view, Renderer, PALETTES,
/* --- math probes ------------------------------------------------- */
/* Integer escape iteration count for a point in the CURRENT view's
iteration budget. Interior points report exactly `maxIter` (the cap).
Boundary note: the parabolic cusp c = 1/4 IS a member of the set — it
converges to the neutral fixed point 1/2, so it correctly reports the cap.
Points just outside it escape with a very large, finite count, e.g.
c = 0.2501 needs 312 iterations (probe with a 4000-iteration budget). */
iterationsAt(re, im, maxIter, mode, cRe, cIm) {
const mi = maxIter || iterationsFor(state.scale);
const julia = (mode || state.mode) === "julia";
const jr = cRe === undefined ? state.jc.re : cRe;
const ji = cIm === undefined ? state.jc.im : cIm;
const mu = escapePoint(re, im, mi, julia, jr, ji, true, state.power !== 2);
return mu < 0 ? mi : ES_LAST_N;
},
/* Full detail: {n, smooth, interior} */
escape(re, im, maxIter) {
const mi = maxIter || iterationsFor(state.scale);
const mu = escapePoint(re, im, mi, state.mode === "julia", state.jc.re, state.jc.im,
true, state.power !== 2);
if (mu < 0) return { n: mi, smooth: -1, interior: true };
return { n: ES_LAST_N, smooth: mu, interior: false };
},
maxIterations() { return iterationsFor(state.scale); },
/* --- view control ------------------------------------------------- */
/* setView({re, im, scale, mode, c:[re,im], palette, iterBoost, smooth}) */
setView(o) {
if (!o) return this.getView();
if (o.re !== undefined) state.center.re = +o.re;
if (o.im !== undefined) state.center.im = +o.im;
if (o.scale !== undefined) state.scale = clampSpan(+o.scale);
if (o.mode) state.mode = o.mode;
if (o.c) { state.jc.re = +o.c[0]; state.jc.im = +o.c[1]; }
if (o.palette && PAL_LUT[o.palette]) state.palette = o.palette;
if (o.iterBoost !== undefined) state.iterBoost = +o.iterBoost;
if (o.smooth !== undefined) state.smooth = !!o.smooth;
if (o.cycle !== undefined) state.cycle = +o.cycle;
if (o.spread !== undefined) state.spread = +o.spread;
if (o.power !== undefined) state.power = +o.power;
syncControls();
if (juliaStageC) setJuliaParam(state.jc.re, state.jc.im, true);
updateStats();
scheduleRender();
return this.getView();
},
getView() {
return {
mode: state.mode, re: state.center.re, im: state.center.im,
scale: state.scale, zoom: zoomOf(state.scale), maxIter: iterationsFor(state.scale),
c: [state.jc.re, state.jc.im], palette: state.palette,
W: view.W, H: view.H, dpr: view.dpr,
onScreen: { cx: view.cx, cy: view.cy, span: view.span, maxIter: view.maxIter },
};
},
/* Apply a rectangle selection in CSS pixels; returns the resulting view.
The rect is snapped to the canvas aspect around its centre first, so
drawn-region === rendered-region. */
zoomToRect(x0, y0, x1, y1) {
const d = view.dpr || 1;
return zoomToRect(x0 * d, y0 * d, x1 * d, y1 * d);
},
lastRect() { return lastRect; },
screenToComplex(xCss, yCss) {
const d = view.dpr || 1;
return pixelToComplex(xCss * d, yCss * d, view.W, view.H, view.cx, view.cy, view.span);
},
complexToScreen(re, im) {
const p = complexToPixel(re, im, view.W, view.H, view.cx, view.cy, view.span);
const d = view.dpr || 1;
return { x: p.x / d, y: p.y / d };
},
back() { goBack(); },
reset() { resetView(); },
historyLength() { return history.length; },
/* --- palettes / colour ------------------------------------------- */
palettes() { return PALETTE_NAMES.slice(); },
setPalette(name) { if (PAL_LUT[name]) { state.palette = name; syncPaletteUI(); applyRecolor(); } },
setCycle(v) { state.cycle = +v; syncControls(); applyRecolor(); },
/* --- rendering ---------------------------------------------------- */
/* Render synchronously to completion at full resolution (used by tests).
Cancels the progressive scheduler so the result is deterministic. */
renderNow() {
Renderer.active = false;
Renderer.token++;
const W = buf.W, H = buf.H;
const p = makeParams(W, H, state.center.re, state.center.im, state.scale,
iterationsFor(state.scale), state.mode, state.jc.re, state.jc.im);
Renderer.params = p;
renderRows(buf.esc, 0, p, 0, H, 1);
view.mode = state.mode; view.cx = state.center.re; view.cy = state.center.im;
view.span = state.scale; view.jcRe = state.jc.re; view.jcIm = state.jc.im;
view.maxIter = p.maxIter;
colorize(buf.esc, W, H, buf.u32, PAL_LUT[state.palette],
PALETTES[state.palette].cyclic, state.smooth, state.cycle, state.spread, p.maxIter);
gctx.putImageData(buf.img, 0, 0);
updateStats();
return { W, H, ms: 0 };
},
/* Raw escape value of an already-rendered pixel (-1 = interior) */
escapeBufferAt(x, y) {
if (!buf) return null;
if (x < 0 || y < 0 || x >= buf.W || y >= buf.H) return null;
return buf.esc[y * buf.W + x];
},
/* Pixel colour of the last presented frame (0xRRGGBB) */
pixelAt(x, y) {
if (!buf) return null;
if (x < 0 || y < 0 || x >= buf.W || y >= buf.H) return null;
const v = buf.u32[y * buf.W + x];
return { r: v & 255, g: (v >> 8) & 255, b: (v >> 16) & 255, a: (v >>> 24) & 255 };
},
isRendering() { return Renderer.active; },
waitIdle() {
return new Promise((res) => {
const t = setInterval(() => {
if (!Renderer.active && Renderer.presented > 0) { clearInterval(t); res(true); }
}, 30);
setTimeout(() => { clearInterval(t); res(false); }, 8000);
});
},
maxZoom() { return CFG.MAX_ZOOM; },
workerCount() { return FORCE_INLINE ? 0 : workers.filter(w => !w.dead).length; },
FORCE_INLINE: () => FORCE_INLINE,
/* --- bookmarks ---------------------------------------------------- */
listBookmarks() { return loadBM(); },
saveBookmark(name) { saveBookmark(name); return loadBM()[0]; },
/* Restore a raw snapshot object (round-trip check for localStorage) */
restoreSnapshot(s) { const ok = restoreSnapshot(s, false); return ok ? this.getView() : null; },
snapshot() { return snapshotState(); },
clearBookmarks() { writeBM([]); renderBM(); },
hash() { return encodeHash(); },
fromHash(h) {
const s = (function (str) {
try { const b = String(str).replace(/^#/, "").slice(2).replace(/-/g, "+").replace(/_/g, "/");
return JSON.parse(atob(b)); } catch (e) { return null; } })(h);
if (!s) return null;
return restoreSnapshot(s, false) ? this.getView() : null;
},
/* internals for advanced probes */
_renderRows: renderRows,
_colorize: colorize,
_pixelToComplex: pixelToComplex,
_buf: () => buf,
_workers: () => workers,
};
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"14ea754c-52b8-447f-bb1f-53541fef8b9d","tokensIn":10543294,"tokensOut":82254,"tokensTotal":10625548,"cost":0.10343888400000002,"turns":86,"toolCalls":85,"failedToolCalls":1,"timestamp":"2026-09-10T17:10:32.286Z"} -->