Fractal Explorer
Qwen 3.8 27B GGUF · 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 53,357 bytes · SHA-256 31d352df83a0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fractal Explorer — Mandelbrot & Julia</title>
<style>
/* ============================================================
Visual style — dark, glassy HUD over a full-bleed canvas
============================================================ */
:root {
--glass: rgba(9, 13, 20, 0.58);
--glass-brd: rgba(255, 255, 255, 0.14);
--txt: #e9eef7;
--txt-dim: #9aa7bd;
--accent: #7cc7ff;
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0; overflow: hidden; background: #04060b; color: var(--txt);
font: 13px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
-webkit-font-smoothing: antialiased; user-select: none; -webkit-user-select: none;
}
.mono { font-family: var(--mono); }
/* ---------- stage & panes ---------- */
#stage { position: fixed; inset: 0; display: flex; }
.pane { position: relative; flex: 1 1 auto; min-width: 0; overflow: hidden; }
#juliaPane { flex: 0 0 36%; min-width: 300px; border-left: 1px solid rgba(255,255,255,0.10); }
#juliaPane[hidden] { display: none; }
.pane canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
canvas.frac { cursor: crosshair; }
canvas.frac:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.pane.panning canvas.frac { cursor: grabbing; }
canvas.ovl { pointer-events: none; }
/* ---------- glass panels ---------- */
.glass {
background: var(--glass); border: 1px solid var(--glass-brd); border-radius: 13px;
backdrop-filter: blur(16px) saturate(1.5); -webkit-backdrop-filter: blur(16px) saturate(1.5);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35);
}
#hud, #toolbar, #jChip { transition: opacity 0.45s ease, transform 0.45s ease; }
body.idle #hud, body.idle #jChip { opacity: 0; transform: translateY(-8px); pointer-events: none; }
body.idle #toolbar { opacity: 0; transform: translateX(-50%) translateY(10px); pointer-events: none; }
/* ---------- top HUD ---------- */
#hud { position: absolute; top: 12px; left: 12px; z-index: 20; padding: 10px 14px 12px; min-width: 238px; }
#hud .mag { font-size: 19px; font-weight: 650; letter-spacing: 0.02em; color: #fff; font-family: var(--mono); }
#hud .coord { color: var(--txt-dim); font-size: 11.5px; margin-top: 2px; font-family: var(--mono); }
#hud .stats { color: var(--txt-dim); font-size: 11.5px; margin-top: 2px; font-family: var(--mono); }
#hud .stats b { color: var(--accent); font-weight: 600; }
#hud .btns { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
/* ---------- buttons ---------- */
button {
appearance: none; border: 1px solid rgba(255,255,255,0.16); background: rgba(255,255,255,0.06);
color: var(--txt); border-radius: 8px; padding: 4px 9px; font: inherit; font-size: 12px;
cursor: pointer; transition: background 0.15s, border-color 0.15s, transform 0.05s;
}
button:hover { background: rgba(255,255,255,0.14); }
button:active { transform: translateY(1px); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
button.on { background: rgba(124,199,255,0.22); border-color: rgba(124,199,255,0.6); color: #fff; }
/* ---------- bottom toolbar ---------- */
#toolbar {
position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); z-index: 20;
display: flex; align-items: center; gap: 12px; padding: 8px 14px; max-width: calc(100vw - 20px); flex-wrap: wrap;
justify-content: center;
}
.tgroup { display: flex; align-items: center; gap: 7px; }
.tlabel { font-size: 10px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--txt-dim); }
.swatch {
width: 21px; height: 21px; border-radius: 7px; border: 1px solid rgba(255,255,255,0.25);
cursor: pointer; padding: 0; transition: transform 0.12s;
}
.swatch:hover { transform: scale(1.14); }
.swatch.on { box-shadow: 0 0 0 2px #04060b, 0 0 0 3.5px var(--accent); }
input[type=range] { width: 104px; accent-color: var(--accent); height: 20px; cursor: pointer; margin: 0; }
input[type=range]:disabled { opacity: 0.35; cursor: default; }
input[type=checkbox] { accent-color: var(--accent); width: 14px; height: 14px; margin: 0; cursor: pointer; }
.sep { width: 1px; height: 22px; background: rgba(255,255,255,0.14); }
#itersVal { font-size: 11px; color: var(--txt-dim); min-width: 40px; text-align: right; }
/* ---------- Julia chip ---------- */
#jChip {
position: absolute; top: 10px; left: 10px; z-index: 15;
display: flex; align-items: center; gap: 8px; padding: 6px 8px 6px 12px;
}
#jChip .title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--txt-dim); }
#jChip .cval { font-size: 11.5px; color: #fff; font-family: var(--mono); }
#jChip button { padding: 2px 7px; font-size: 12px; }
/* ---------- toast ---------- */
#toast {
position: absolute; top: 14px; left: 50%; transform: translateX(-50%) translateY(-8px); z-index: 40;
padding: 7px 14px; font-size: 12px; color: #fff; opacity: 0; pointer-events: none;
transition: opacity 0.3s, transform 0.3s;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---------- bookmarks panel ---------- */
#bmPanel { position: absolute; top: 104px; left: 12px; z-index: 30; width: 252px; padding: 10px; display: none; }
#bmPanel.open { display: block; }
#bmPanel input {
width: 100%; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.18);
color: var(--txt); border-radius: 8px; padding: 5px 8px; font: inherit; margin-bottom: 8px;
}
#bmPanel input:focus { outline: none; border-color: var(--accent); }
.bmrow { display: flex; align-items: center; gap: 6px; padding: 3px 2px; }
.bmrow .nm {
flex: 1 1 auto; font-size: 12px; cursor: pointer; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; padding: 2px 4px; border-radius: 6px;
}
.bmrow .nm:hover { background: rgba(255,255,255,0.10); color: var(--accent); }
.bmrow button { padding: 1px 7px; font-size: 11px; }
.bmempty { color: var(--txt-dim); font-size: 11.5px; padding: 4px 2px; }
/* ---------- help modal ---------- */
#help {
position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center;
background: rgba(2, 4, 8, 0.55); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
}
#help.open { display: flex; }
#help .card { width: min(440px, calc(100vw - 36px)); max-height: calc(100vh - 40px); overflow: auto; padding: 18px 20px; }
#help h1 { font-size: 15px; margin: 0 0 3px; }
#help .sub { color: var(--txt-dim); font-size: 11.5px; margin-bottom: 12px; }
#help table { width: 100%; border-collapse: collapse; font-size: 12px; }
#help td { padding: 4px 4px; vertical-align: top; }
#help td.k { color: var(--accent); white-space: nowrap; width: 44%; font-family: var(--mono); font-size: 11px; }
#help .note { color: var(--txt-dim); font-size: 11px; margin-top: 10px; }
#help .close { margin-top: 14px; width: 100%; }
@media (max-width: 860px) {
#juliaPane { position: absolute; top: 0; right: 0; bottom: 0; width: 46%; min-width: 240px; z-index: 10; }
#toolbar { bottom: 8px; }
#hud { min-width: 200px; }
}
</style>
</head>
<body>
<div id="stage">
<div id="mainPane" class="pane">
<canvas id="mainCanvas" class="frac" tabindex="0" role="img" aria-label="Mandelbrot set viewer"
aria-keyshortcuts="ArrowLeft ArrowRight ArrowUp ArrowDown + - 0"></canvas>
<canvas id="mainOvl" class="ovl" aria-hidden="true"></canvas>
</div>
<div id="juliaPane" class="pane" hidden>
<canvas id="juliaCanvas" class="frac" tabindex="0" role="img" aria-label="Julia set viewer"></canvas>
<canvas id="juliaOvl" class="ovl" aria-hidden="true"></canvas>
<div id="jChip" class="glass">
<span class="title">Julia</span>
<span class="cval" id="jCval">c = 0 + 0i</span>
<button id="jRecenter" title="Recenter Julia view">⟳</button>
<button id="jClose" title="Close Julia pane (Esc)">✕</button>
</div>
</div>
</div>
<div id="hud" class="glass">
<div class="mag" id="hudMag">×1.000</div>
<div class="coord" id="hudCoord">re −0.6000000 · im 0.0000000</div>
<div class="stats">iters <b id="hudIters">–</b> · <b id="hudFps">–</b> fps · <span id="hudRender">render…</span></div>
<div class="btns">
<button id="btnBack" title="Zoom history back (Backspace)">↩ back</button>
<button id="btnReset" title="Reset view (0)">⌂</button>
<button id="btnSave" title="Save bookmark (S)">★ save</button>
<button id="btnViews" title="Saved views">views</button>
<button id="btnHelp" title="Help (?)">?</button>
</div>
</div>
<div id="toolbar" class="glass">
<div class="tgroup" id="palChips" role="group" aria-label="Color palette"></div>
<div class="tgroup">
<span class="tlabel" id="offLabel">drift</span>
<input type="range" id="offSlider" min="0" max="1" step="0.001" value="0.12" aria-labelledby="offLabel" aria-label="Palette offset">
<button id="btnDrift" title="Animate palette drift (recolor pass only)">⇄</button>
</div>
<div class="sep"></div>
<div class="tgroup">
<span class="tlabel" id="itAutoLabel">auto iters</span>
<input type="checkbox" id="itersAuto" checked aria-labelledby="itAutoLabel">
<input type="range" id="itersSlider" min="60" max="6000" step="20" value="240" aria-label="Iteration limit" disabled>
<span class="mono" id="itersVal">–</span>
</div>
</div>
<div id="bmPanel" class="glass">
<input id="bmName" placeholder="Bookmark name…" maxlength="40" aria-label="Bookmark name">
<div id="bmList"></div>
</div>
<div id="toast" class="glass" role="status" aria-live="polite"></div>
<div id="help" role="dialog" aria-modal="true" aria-label="Help">
<div class="card glass">
<h1>Fractal Explorer</h1>
<div class="sub">Mandelbrot & Julia · escape-time · smooth coloring · double precision to ×10<sup>13</sup></div>
<table>
<tr><td class="k">left-drag</td><td>rectangle zoom — release to zoom into it</td></tr>
<tr><td class="k">middle / right / ⇧ + drag</td><td>pan</td></tr>
<tr><td class="k">wheel</td><td>zoom toward cursor</td></tr>
<tr><td class="k">click (Mandelbrot)</td><td>open Julia set at that c</td></tr>
<tr><td class="k">J</td><td>toggle Julia pane (c = view center)</td></tr>
<tr><td class="k">← ↑ → ↓</td><td>pan the focused view</td></tr>
<tr><td class="k">+ / −</td><td>zoom in / out</td></tr>
<tr><td class="k">0</td><td>reset view</td></tr>
<tr><td class="k">Backspace</td><td>zoom-history back</td></tr>
<tr><td class="k">S</td><td>save bookmark</td></tr>
<tr><td class="k">Esc</td><td>close panels / Julia pane</td></tr>
</table>
<div class="note">While hovering the Mandelbrot view with the Julia pane open, the Julia set live-previews
the c under the cursor. Iterations scale automatically with zoom depth (manual override in the toolbar).
Zoom is capped at ×10<sup>13</sup> — beyond that IEEE-754 double precision can no longer resolve new structure.</div>
<button class="close" id="helpClose">Close</button>
</div>
</div>
<script>
'use strict';
/* ================================================================
SECTION 1 · TUNABLE CONSTANTS
================================================================ */
const CFG = {
// iteration budget — scales with zoom: clamp(BASE + STEP·log10(1+mag), MIN, MAX)
ITER_BASE: 240, ITER_STEP: 130, ITER_MIN: 60, ITER_MAX: 6000,
ITER_DEBUG: 3000, // cap used by window.fractal.iterationsAt()
// zoom / navigation
WHEEL_ZOOM: 1.2, // magnification factor per wheel detent (smooth)
ZOOM_CAP_MAG: 1e13, // hard cap — double precision degrades beyond this
ZOOM_CAP_WARN: 3e12,
MAG_REF: 1 / 1280, // pixels-per-world-unit at ×1 magnification
MAX_SCALE: 1e-3, // coarsest allowed scale (min zoom)
DRAG_MIN_PX: 5, // drag shorter than this = click, not rectangle
ARROW_PAN: 0.12, // world fraction of view width per arrow key
KEY_ZOOM: 1.5,
// progressive rendering — resolution ladder (each pass refines the previous)
PROGRESSIVE_PASSES: [0.25, 0.5, 1],
TILE: 256, // compositing tile edge (px)
MAX_WORKERS: 4, // 0 forces the main-thread render path
// coloring
SMOOTH: true, // continuous (fractional) escape count
ESCAPE2: 256, // |z|^2 bailout — larger = smoother color
PALETTE_CYCLE: 1.0,
DRIFT_SPEED: 0.02,
PREVIEW_ITERS: 260,
PREVIEW_INTERVAL_MS: 30,
// ui
IDLE_MS: 3000,
DPR_MAX: 1.5, // cap devicePixelRatio for performance
};
// relative magnification × (1 = the default framing)
function magnification(view) { return view ? (view.scale / CFG.MAG_REF) : (S.view.scale / CFG.MAG_REF); }
const PALETTES = [
{ id: 'fire', cyclic: false, stops: [
[0.00, 6, 2, 22], [0.22, 88, 12, 36], [0.45, 200, 56, 22], [0.68, 250, 142, 20], [1.00, 255, 245, 190]] },
{ id: 'ocean', cyclic: false, stops: [
[0.00, 3, 10, 28], [0.30, 10, 56, 108], [0.55, 20, 122, 160], [0.78, 88, 198, 214], [1.00, 228, 250, 255]] },
{ id: 'mono', cyclic: false, stops: [
[0.00, 4, 6, 12], [0.40, 92, 98, 110], [0.72, 190, 198, 210], [1.00, 255, 255, 255]] },
{ id: 'psy', cyclic: true, stops: [
[0.00, 255, 30, 90], [0.16, 255, 120, 40], [0.33, 250, 230, 40], [0.50, 40, 220, 120],
[0.66, 30, 160, 240], [0.83, 120, 60, 240], [1.00, 255, 30, 90]] },
];
/* ================================================================
SECTION 2 · PALETTE PRECOMPUTATION (LUTs — O(1) per pixel, smooth
interpolation between stops; interior tail stored as pure black)
================================================================ */
const LUT_N = 4096;
const LUT = [];
const LUT_SCALE = 12.0; // smooth-count range spanning one LUT lap
function buildLut(pal) {
const lut = new Uint8ClampedArray(LUT_N * 4);
const stops = pal.stops, n = stops.length;
for (let i = 0; i < LUT_N; i++) {
const p = (i / (LUT_N - 1)) * (pal.cyclic ? 1 : 0.985);
let s = 0;
while (s < n - 2 && p > stops[s + 1][0]) s++;
const a = stops[s], b = stops[Math.min(s + 1, n - 1)];
const t = Math.max(0, Math.min(1, (p - a[0]) / Math.max(1e-9, b[0] - a[0])));
const o = i * 4;
lut[o] = a[1] + (b[1] - a[1]) * t;
lut[o + 1] = a[2] + (b[2] - a[2]) * t;
lut[o + 2] = a[3] + (b[3] - a[3]) * t;
lut[o + 3] = 255;
}
for (let i = 0; i < 8; i++) { // interior tail → black
const o = (LUT_N - 1 - i) * 4;
lut[o] = lut[o + 1] = lut[o + 2] = 0; lut[o + 3] = 255;
}
return lut;
}
PALETTES.forEach(p => LUT.push(buildLut(p)));
/* ================================================================
SECTION 3 · VIEW STATE
================================================================ */
function makeView(re, im, scale) { return { re, im, scale }; }
function defaultMandView() { return makeView(-0.6, 0, 2.6 / 1280); } // 2.6 world units wide
const S = {
view: defaultMandView(),
jRe: -0.70176, jIm: 0.0250998, // Julia c (classic default)
jView: makeView(0, 0, 3.1 / 720),
juliaOpen: false,
palette: 1, // 'ocean'
offset: 0.12,
drift: false,
itersAuto: true,
itersManual: 240,
history: [],
dirty: true,
hoverC: null, previewDue: false, _prevPreviewT: 0,
maxItersCache: 0,
};
const MAX_HISTORY = 80;
/* ================================================================
SECTION 4 · RENDER PLANNING (iterations vs magnification)
================================================================ */
function itersForMag(mag) {
return Math.max(CFG.ITER_MIN, Math.min(CFG.ITER_MAX,
Math.round(CFG.ITER_BASE + CFG.ITER_STEP * Math.log10(1 + mag))));
}
function activeIters() {
return S.itersAuto ? itersForMag(magnification()) : S.itersManual;
}
function renderKey() {
return S.view.re + '|' + S.view.im + '|' + S.view.scale + '|' +
(S.juliaOpen ? 'j' + S.jRe + ',' + S.jIm : 'm') + '|' + activeIters();
}
/* ================================================================
SECTION 5 · CORE MATH — allocation-free pixel evaluation
Returns smooth orbit count: n + 1 − log2(log2|z|²) (cyclic)
Values >= maxIt−0.5 are interior (never escaped).
================================================================ */
const LOG2_HALF = 0.7213475204444817; // 0.5·log2(e)
const SMOOTH_C = 2.4415374596901487; // log2(log2(256)) for ESCAPE2=256
function fractalPixel(zRe, zIm, cRe, cIm, maxIt, julia) {
const B2 = CFG.ESCAPE2;
if (julia) {
let zr = zRe, zi = zIm;
for (let n = 0; n < maxIt; n++) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > B2)
return CFG.SMOOTH ? n + 1 - Math.log(zr2 + zi2) * LOG2_HALF + SMOOTH_C : n;
zi = 2 * zr * zi + cIm;
zr = zr2 - zi2 + cRe;
}
return maxIt;
}
let zr = zRe, zi = zIm;
for (let n = 0; n < maxIt; n++) {
const zr2 = zr * zr, zi2 = zi * zi;
if (zr2 + zi2 > B2)
return CFG.SMOOTH ? n + 1 - Math.log(zr2 + zi2) * LOG2_HALF + SMOOTH_C : n;
zi = 2 * zr * zi + cIm;
zr = zr2 - zi2 + cRe;
}
return maxIt;
}
// Debug: Mandelbrot iteration count at c=(re,im) with a large cap.
// result >= ITER_DEBUG−0.5 ⇒ interior at that cap (c=0, c=−1).
function iterationsAt(re, im) {
return fractalPixel(re, im, re, im, CFG.ITER_DEBUG, false);
}
// Fill a preallocated Float32 buffer with orbit counts for a region.
function computeRegion(buf, w, h, view, cRe, cIm, julia, maxIt) {
const s = view.scale;
const baseRe = view.re - (w / 2) * s, baseIm = view.im + (h / 2) * s;
let p = 0;
for (let row = 0; row < h; row++) {
const zi = baseIm - row * s;
for (let col = 0; col < w; col++)
buf[p++] = fractalPixel(baseRe + col * s, zi, cRe, cIm, maxIt, julia);
}
}
/* ================================================================
SECTION 6 · VIEW OPS — clamp to precision cap, history, setView
================================================================ */
let _capWarned = false;
function clampView(v) {
if (v.scale > 1 / CFG.ZOOM_CAP_MAG) {
v.scale = 1 / CFG.ZOOM_CAP_MAG;
if (!_capWarned) { _capWarned = true;
toast('Precision cap ×10¹³ — beyond this, double precision degrades'); }
}
return v;
}
function pushHistory() {
S.history.push({ re: S.view.re, im: S.view.im, scale: S.view.scale });
if (S.history.length > MAX_HISTORY) S.history.shift();
}
function goBack() {
const h = S.history.pop();
if (!h) { toast('No zoom history'); return; }
S.view = clampView(makeView(h.re, h.im, h.scale));
S.dirty = true; writeHash();
}
function setView(v) {
if (v && v.re !== undefined) S.view.re = +v.re;
if (v && v.im !== undefined) S.view.im = +v.im;
if (v && v.scale !== undefined) S.view.scale = Math.max(1e-15, +v.scale);
S.view = clampView(S.view);
S.dirty = true; writeHash();
}
function commitView() { S.dirty = true; writeHash(); }
/* ================================================================
SECTION 7 · COLORIZE (typed-array LUT lookup, no per-pixel alloc)
Writes little-endian ABGR into a Uint32 view of the ImageData.
================================================================ */
function tileColorize(out32, buf, outOff, off, maxIt) {
const lut = LUT[S.palette], n = buf.length;
for (let i = 0, o = outOff; i < n; i++, o++) {
const v = buf[i]; let idx;
if (v >= maxIt - 0.5) idx = LUT_N - 4; // interior → black
else {
const t = v / LUT_SCALE + off; // smooth index → no bands
idx = (t - Math.floor(t)) * (LUT_N - 8) | 0;
}
const q = idx * 4;
out32[o] = (255 << 24) | (lut[q] << 16) | (lut[q + 1] << 8) | lut[q + 2];
}
}
/* ================================================================
SECTION 8 · WEB WORKERS (inline via Blob URL)
Each worker computes one tile into a transferred Float32Array and
posts it back. A per-pane job counter cancels in-flight work: a new
render bumps P.job, and any stale tile message is discarded — no
ghost tiles, no freeze when the view changes mid-render.
================================================================ */
let workers = [];
let workerOk = (CFG.MAX_WORKERS > 0);
function initWorkers() {
if (!workerOk) return;
const src =
'self.onmessage = function (e) {\n' +
' var d = e.data, buf = d.buf;\n' +
' var baseRe = d.re, baseIm = d.im, s = d.s, w = d.w, h = d.h;\n' +
' var maxIt = d.maxIt, j = d.j, cRe = d.cre, cIm = d.cim, B2 = d.b2, sm = d.sm;\n' +
' var p = 0;\n' +
' for (var row = 0; row < h; row++) {\n' +
' var zi = baseIm - row * s;\n' +
' for (var col = 0; col < w; col++) {\n' +
' var zr = baseRe + col * s;\n' +
' var cr = j ? cRe : zr, ci = j ? cIm : zi;\n' +
' var n, zr2, zi2, t;\n' +
' for (n = 0; n < maxIt; n++) {\n' +
' zr2 = zr * zr; zi2 = zi * zi;\n' +
' if (zr2 + zi2 > B2) {\n' +
' if (sm) { t = Math.log(zr2 + zi2) * 0.7213475204444817; buf[p++] = n + 1 - t + 2.4415374596901487; }\n' +
' else buf[p++] = n;\n' +
' break;\n' +
' }\n' +
' zi = 2 * zr * zi + ci;\n' +
' zr = zr2 - zi2 + cr;\n' +
' }\n' +
' if (n === maxIt) buf[p++] = maxIt;\n' +
' }\n' +
' }\n' +
' self.postMessage({ id: d.id, buf: buf }, [buf.buffer]);\n' +
'};';
try {
const url = URL.createObjectURL(new Blob([src], { type: 'text/javascript' }));
for (let i = 0; i < CFG.MAX_WORKERS; i++) {
const w = new Worker(url);
w.onmessage = e => { if (w._cb) w._cb(e.data); };
w.onerror = () => {
workerOk = false;
toast('Web Worker unavailable — using main thread');
};
workers.push(w);
}
} catch (err) {
workerOk = false;
toast('Web Worker unavailable — using main thread');
}
}
function killWorkers() { workers.forEach(w => w.terminate()); workers = []; }
/* ================================================================
SECTION 9 · RENDER ORCHESTRATION — progressive passes (0.25 → 0.5
→ 1.0 resolution) in 256px tiles; main-thread fallback is
time-sliced. recolorAll() repaints from the cached orbit buffer
without recomputing a single iteration (palette drift path).
================================================================ */
function beginRender(P, spec) {
if (!P.ctx || P.w < 8) return;
P.job = (P.job || 0) + 1;
const pw = P.w, ph = P.h;
const r = spec.passes[spec.pass];
const tw = Math.max(16, Math.floor(pw * r)), th = Math.max(16, Math.floor(ph * r));
const view = spec.view, s = view.scale;
const baseRe = view.re - (pw / 2) * s;
const baseIm = view.im + (ph / 2) * s;
const off = P._off || (P._off = document.createElement('canvas'));
off.width = tw; off.height = th;
const octx = off.getContext('2d');
const oimg = octx.createImageData(tw, th);
const o32 = new Uint32Array(oimg.data.buffer);
const TJ = Math.min(CFG.TILE, Math.max(48, tw >> 2));
const nx = Math.ceil(tw / TJ), ny = Math.ceil(th / TJ);
const jobState = { tiles: nx * ny, done: 0 };
P.current = jobState;
P.maxIt = spec.maxIt;
const lastPass = spec.pass === spec.passes.length - 1;
const fullRes = r >= 1;
document.getElementById('hudRender').textContent = r < 1 ? 'pass ' + r + '…' : 'full…';
function completePass() {
if (spec.pass + 1 < spec.passes.length)
beginRender(P, Object.assign({}, spec, { pass: spec.pass + 1 }));
else if (P.key === 'main') {
P.renderedKey = renderKey();
S.maxItersCache = spec.maxIt;
refreshHud();
document.getElementById('hudRender').textContent = 'done';
}
}
function storeFull(tid, tbuf) {
if (P.fullBuf === undefined || P.fullBuf.length !== pw * ph)
P.fullBuf = new Float32Array(pw * ph);
const tx = tid % nx, ty = (tid / nx) | 0;
const w2 = Math.min(TJ, tw - tx * TJ), h2 = Math.min(TJ, th - ty * TJ);
for (let yy = 0; yy < h2; yy++)
P.fullBuf.set(tbuf.subarray(yy * w2, (yy + 1) * w2), (ty * TJ + yy) * pw + tx * TJ);
}
function tileDone(tid, tbuf) {
if (P.current !== jobState) return; // cancelled — discard
const tx = tid % nx, ty = (tid / nx) | 0;
const w2 = Math.min(TJ, tw - tx * TJ), h2 = Math.min(TJ, th - ty * TJ);
tileColorize(o32, tbuf, (ty * TJ * tw + tx * TJ) * 4, S.offset * CFG.PALETTE_CYCLE, spec.maxIt);
octx.putImageData(oimg, 0, 0);
P.ctx.drawImage(off, tx * TJ, ty * TJ, w2, h2, tx * TJ, ty * TJ, w2, h2);
if (P.key === 'main' && lastPass && fullRes) storeFull(tid, tbuf);
if (++jobState.done === jobState.tiles) completePass();
}
if (workerOk && workers.length) {
const queue = [];
for (let ty = 0; ty < ny; ty++) for (let tx = 0; tx < nx; tx++)
queue.push({ tid: ty * nx + tx, w2: Math.min(TJ, tw - tx * TJ), h2: Math.min(TJ, th - ty * TJ),
re: baseRe + tx * TJ * s, im: baseIm - ty * TJ * s });
const free = [];
for (let i = 0; i < workers.length; i++) free.push(i);
function dispatch() {
if (P.current !== jobState) return; // cancelled
while (free.length && queue.length) {
const wi = free.pop(), item = queue.shift();
const tbuf = new Float32Array(item.w2 * item.h2);
workers[wi]._cb = d => { free.push(wi); dispatch(); tileDone(item.tid, d.buf); };
workers[wi].postMessage({ id: item.tid, buf: tbuf, re: item.re, im: item.im, s,
w: item.w2, h: item.h2, maxIt: spec.maxIt, j: spec.julia ? 1 : 0,
cre: spec.cRe, cim: spec.cIm, b2: CFG.ESCAPE2, sm: CFG.SMOOTH ? 1 : 0 },
[tbuf.buffer]);
}
}
dispatch();
} else {
// main-thread fallback — row chunks within a 14ms time slice
const buf = new Float32Array(tw * th);
let row = 0;
function step() {
if (P.current !== jobState) return; // cancelled
const t0 = performance.now();
while (row < th && performance.now() - t0 < 14) {
for (let c = 0; c < tw; c++)
buf[row * tw + c] = fractalPixel(baseRe + c * s, baseIm + row * s,
spec.cRe, spec.cIm, spec.maxIt, spec.julia);
row++;
}
if (row > 0) {
tileColorize(o32, buf, 0, S.offset * CFG.PALETTE_CYCLE, spec.maxIt);
octx.putImageData(oimg, 0, 0);
P.ctx.drawImage(off, 0, 0, tw, row, 0, 0, tw, row);
}
if (row < th) requestAnimationFrame(step);
else {
if (P.key === 'main' && lastPass && fullRes) P.fullBuf = buf.slice();
completePass();
}
}
requestAnimationFrame(step);
}
}
/* recolor-only repaint (palette/drift) — reuses the cached orbit buffer */
function recolorAll(P) {
if (!P.fullBuf || P.img32 === undefined) return;
tileColorize(P.img32, P.fullBuf, 0, S.offset * CFG.PALETTE_CYCLE, P.maxIt || activeIters());
P.ctx.putImageData(P.img, 0, 0);
}
/* ================================================================
SECTION 10 · PANES / CANVAS SETUP
================================================================ */
const panes = {};
function setupPane(key, canvas, ovl) {
const P = {
key, canvas, ovl,
ctx: canvas.getContext('2d'), octx: ovl.getContext('2d'),
w: 0, h: 0, job: 0, current: null, fullBuf: undefined, maxIt: 0,
img: null, img32: null, _off: null,
ovRect: null, pane: canvas.parentElement,
};
panes[key] = P;
resizePane(key);
return P;
}
function resizePane(key) {
const P = panes[key];
const dpr = Math.min(window.devicePixelRatio || 1, CFG.DPR_MAX);
const w = Math.max(2, Math.round(P.canvas.clientWidth * dpr));
const h = Math.max(2, Math.round(P.canvas.clientHeight * dpr));
if (w === P.w && h === P.h) return;
P.w = w; P.h = h;
P.canvas.width = w; P.canvas.height = h;
P.ovl.width = w; P.ovl.height = h;
P.img = P.ctx.createImageData(w, h);
P.img32 = new Uint32Array(P.img.data.buffer);
P.fullBuf = undefined; // invalidate cached orbits
S.dirty = true;
}
/* ================================================================
SECTION 11 · COORDINATE MATH (pixel ↔ world, exact)
pixel x → re = view.re + (x − w/2)·scale
pixel y → im = view.im − (y − h/2)·scale
================================================================ */
function canvasPos(P, ev) {
const r = P.canvas.getBoundingClientRect();
return { x: ev.clientX - r.left, y: ev.clientY - r.top, w: r.width, h: r.height };
}
function toWorld(P, x, y) {
const v = S.view, s = v.scale;
return { re: v.re + (x - P.w / 2) * s, im: v.im - (y - P.h / 2) * s };
}
function zoomAtAnchor(P, factor, ax, ay) {
const v = S.view;
const s2 = Math.min(1e-3, Math.max(1 / CFG.ZOOM_CAP_MAG, v.scale / factor));
if (Math.abs(s2 - v.scale) < 1e-18) return;
pushHistory();
const reA = v.re + (ax - P.w / 2) * v.scale;
const imA = v.im - (ay - P.h / 2) * v.scale;
v.re = reA - (ax - P.w / 2) * s2;
v.im = imA + (ay - P.h / 2) * s2;
v.scale = s2;
clampView(v);
S.dirty = true; writeHash();
}
/* Exact rectangle zoom: the framed region is exactly the selected
rectangle, aspect-padded about its CENTER (no silent shift). */
function zoomRect(P, x0, y0, x1, y1) {
const v = S.view;
const rw = Math.abs(x1 - x0), rh = Math.abs(y1 - y0);
if (rw < CFG.DRAG_MIN_PX && rh < CFG.DRAG_MIN_PX) return false;
const aspect = P.w / P.h;
let rw2 = rw, rh2 = rh;
if (rw2 / P.w > rh2 / P.h) rh2 = rw2 / aspect; // pad height
else rw2 = rh2 * aspect; // pad width
const s2 = Math.max(1 / CFG.ZOOM_CAP_MAG, rw2 / P.w);
const c = toWorld(P, (x0 + x1) / 2, (y0 + y1) / 2);
pushHistory();
v.re = c.re; v.im = c.im; v.scale = s2;
clampView(v);
S.dirty = true; writeHash();
return true;
}
function zoomAtAnchorJ(P, factor, ax, ay) {
const v = S.jView;
const s2 = Math.min(1e-3, Math.max(1 / CFG.ZOOM_CAP_MAG, v.scale / factor));
const reA = v.re + (ax - P.w / 2) * v.scale;
const imA = v.im - (ay - P.h / 2) * v.scale;
v.re = reA - (ax - P.w / 2) * s2; v.im = imA + (ay - P.h / 2) * s2; v.scale = s2;
S.dirty = true;
}
function zoomRectJ(P, x0, y0, x1, y1) {
const v = S.jView;
const rw = Math.abs(x1 - x0), rh = Math.abs(y1 - y0);
if (rw < CFG.DRAG_MIN_PX && rh < CFG.DRAG_MIN_PX) return;
const aspect = P.w / P.h;
let rw2 = rw, rh2 = rh;
if (rw2 / P.w > rh2 / P.h) rh2 = rw2 / aspect; else rw2 = rh2 * aspect;
const cxp = (x0 + x1) / 2, cyp = (y0 + y1) / 2;
const c = { re: v.re + (cxp - P.w / 2) * v.scale, im: v.im - (cyp - P.h / 2) * v.scale };
v.scale = Math.max(1 / CFG.ZOOM_CAP_MAG, rw2 / P.w);
v.re = c.re; v.im = c.im;
S.dirty = true;
}
/* ================================================================
SECTION 12 · POINTER INTERACTION
left-drag: rectangle zoom (live overlay) · click: Julia on Mandelbrot
middle/right/shift-drag: pan · wheel: zoom toward cursor
================================================================ */
function bindCanvas(P) {
const cv = P.canvas;
let drag = null;
cv.addEventListener('pointerdown', e => {
wake();
const p = canvasPos(P, e);
const pan = e.button === 1 || e.button === 2 || e.shiftKey;
drag = { x0: p.x, y0: p.y, x: p.x, y: p.y, pan, moved: false };
cv.setPointerCapture(e.pointerId);
cv.focus();
if (pan) P.pane.classList.add('panning');
e.preventDefault();
});
cv.addEventListener('pointermove', e => {
const p = canvasPos(P, e);
updateHoverC(P, p);
if (!drag) { drawReticle(P, p); return; }
drag.x = p.x; drag.y = p.y;
if (Math.hypot(p.x - drag.x0, p.y - drag.y0) > CFG.DRAG_MIN_PX) drag.moved = true;
if (drag.pan) {
S.view.re += (p.x - drag.x) * S.view.scale;
S.view.im -= (p.y - drag.y) * S.view.scale;
drag.x = p.x; drag.y = p.y;
S.dirty = true;
P.ovRect = null; P.octx.clearRect(0, 0, P.w, P.h);
} else if (drag.moved) {
P.ovRect = { x0: drag.x0, y0: drag.y0, x1: p.x, y1: p.y };
drawOvl(P);
}
});
const finish = e => {
if (!drag) return;
const p = canvasPos(P, e);
const wasPan = drag.pan, wasClick = !drag.moved;
const r = { x0: drag.x0, y0: drag.y0, x1: p.x, y1: p.y };
drag = null;
P.pane.classList.remove('panning');
P.ovRect = null; P.octx.clearRect(0, 0, P.w, P.h);
if (wasPan) { commitView(); return; }
if (wasClick) {
if (P.key === 'main') openJulia(toWorld(P, p.x, p.y).re, toWorld(P, p.x, p.y).im, true);
return;
}
if (P.key === 'main') zoomRect(P, r.x0, r.y0, r.x1, r.y1);
else zoomRectJ(P, r.x0, r.y0, r.x1, r.y1);
};
cv.addEventListener('pointerup', finish);
cv.addEventListener('pointercancel', finish);
cv.addEventListener('contextmenu', e => e.preventDefault());
cv.addEventListener('wheel', e => {
e.preventDefault(); wake();
const p = canvasPos(P, e);
if (P.key === 'main') zoomAtAnchor(P, Math.pow(CFG.WHEEL_ZOOM, -e.deltaY / 100), p.x, p.y);
else zoomAtAnchorJ(P, Math.pow(CFG.WHEEL_ZOOM, -e.deltaY / 100), p.x, p.y);
}, { passive: false });
cv.addEventListener('dblclick', e => {
const p = canvasPos(P, e);
if (P.key === 'main') zoomAtAnchor(P, 4, p.x, p.y);
else zoomAtAnchorJ(P, 4, p.x, p.y);
});
cv.addEventListener('pointerleave', () => {
P.octx.clearRect(0, 0, P.w, P.h);
if (P.key === 'main') S.hoverC = null;
});
}
/* hover on Mandelbrot → live Julia preview of c under cursor */
function updateHoverC(P, p) {
if (P.key !== 'main' || !S.juliaOpen) return;
const c = toWorld(P, p.x, p.y);
const prev = S.hoverC;
if (prev && Math.abs(prev.re - c.re) * S.view.scale < 0.4 &&
Math.abs(prev.im - c.im) * S.view.scale < 0.4) return;
S.hoverC = c;
S.previewDue = true;
}
/* ================================================================
SECTION 13 · JULIA PANE
================================================================ */
function openJulia(re, im, recenter) {
S.juliaOpen = true;
S.jRe = re; S.jIm = im;
document.getElementById('juliaPane').hidden = false;
resizePane('julia');
document.getElementById('jCval').textContent = 'c = ' + fmtC(re, im);
if (recenter) {
S.jView = makeView(0, 0, 3.1 / Math.max(1, panes.julia.h));
S.dirty = true;
}
}
function closeJulia() {
if (!S.juliaOpen) return;
S.juliaOpen = false; S.hoverC = null;
document.getElementById('juliaPane').hidden = true;
resizePane('main');
writeHash();
}
function setJuliaC(re, im) {
S.jRe = re; S.jIm = im;
document.getElementById('jCval').textContent = 'c = ' + fmtC(re, im);
S.dirty = true;
}
function fmtC(re, im) {
const f = x => Math.abs(x) >= 100 ? x.toExponential(4) : x.toFixed(5);
return f(re) + (im < 0 ? ' − ' : ' + ') + f(Math.abs(im)) + 'i';
}
/* ================================================================
SECTION 14 · OVERLAYS — inverted-color zoom rect + reticle
================================================================ */
function drawOvl(P) {
const c = P.octx;
c.clearRect(0, 0, P.w, P.h);
if (!P.ovRect) return;
const { x0, y0, x1, y1 } = P.ovRect;
const X = Math.min(x0, x1), Y = Math.min(y0, y1),
Wd = Math.abs(x1 - x0), Ht = Math.abs(y1 - y0);
const dpr = Math.min(window.devicePixelRatio || 1, CFG.DPR_MAX);
// inversion effect: white wash, punched hole → fractal shows through inverted
c.save();
c.fillStyle = 'rgba(255,255,255,0.06)';
c.fillRect(X, Y, Wd, Ht);
c.globalCompositeOperation = 'destination-out';
c.fillRect(X, Y, Wd, Ht);
c.restore();
// crisp double border
c.lineWidth = 2 * dpr;
c.strokeStyle = 'rgba(255,255,255,0.95)';
c.strokeRect(X + dpr, Y + dpr, Math.max(1, Wd - 2 * dpr), Math.max(1, Ht - 2 * dpr));
c.lineWidth = dpr;
c.strokeStyle = 'rgba(0,0,0,0.85)';
c.strokeRect(X + 3 * dpr, Y + 3 * dpr, Math.max(1, Wd - 6 * dpr), Math.max(1, Ht - 6 * dpr));
const label = '×' + (P.w / Math.max(1, Wd)).toExponential(2);
c.font = (11 * dpr) + 'px ui-monospace, monospace';
c.fillStyle = 'rgba(255,255,255,0.92)';
c.fillText(label, X + 6 * dpr, Math.max(14 * dpr, Y - 6 * dpr));
}
function drawReticle(P, p) {
const c = P.octx;
c.clearRect(0, 0, P.w, P.h);
if (!S.juliaOpen || p.x < 0 || p.y < 0 || p.x > P.w || p.y > P.h) return;
const dpr = Math.min(window.devicePixelRatio || 1, CFG.DPR_MAX);
const r = 14 * dpr, L = 9 * dpr;
c.strokeStyle = 'rgba(255,255,255,0.9)';
c.lineWidth = 1.25 * dpr;
c.beginPath();
c.arc(p.x, p.y, r, 0, Math.PI * 2);
c.moveTo(p.x - r - L, p.y); c.lineTo(p.x - r + 3 * dpr, p.y);
c.moveTo(p.x + r - 3 * dpr, p.y); c.lineTo(p.x + r + L, p.y);
c.moveTo(p.x, p.y - r - L); c.lineTo(p.x, p.y - r + 3 * dpr);
c.moveTo(p.x, p.y + r - 3 * dpr); c.lineTo(p.x, p.y + r + L);
c.stroke();
const cwp = toWorld(P, p.x, p.y);
c.font = (10.5 * dpr) + 'px ui-monospace, monospace';
c.fillStyle = 'rgba(255,255,255,0.92)';
c.fillText('c = ' + cwp.re.toFixed(7) + ' ' + (cwp.im < 0 ? '− ' : '+ ') +
Math.abs(cwp.im).toFixed(7) + 'i',
p.x + r + 6 * dpr, p.y - r - 2 * dpr);
}
/* ================================================================
SECTION 15 · HUD / TOAST / IDLE HIDE
================================================================ */
function fmtMag(m) {
if (m >= 1e5) return '×' + m.toExponential(3);
return '×' + m.toFixed(3);
}
function refreshHud() {
const v = S.view, mag = 1 / v.scale;
document.getElementById('hudMag').textContent = fmtMag(mag);
document.getElementById('hudCoord').textContent =
're ' + v.re.toFixed(9) + ' · im ' + v.im.toFixed(9);
const ai = activeIters();
if (ai !== S._aiShown) { S._aiShown = ai; document.getElementById('itersVal').textContent = ai; }
document.getElementById('hudIters').textContent = S.maxItersCache || ai;
document.getElementById('mainCanvas').setAttribute('aria-label',
'Mandelbrot set viewer, magnification ' + fmtMag(mag));
}
let toastT = 0;
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(toastT); toastT = setTimeout(() => t.classList.remove('show'), 2400);
}
let idleT = 0;
function wake() {
document.body.classList.remove('idle');
clearTimeout(idleT);
idleT = setTimeout(() => document.body.classList.add('idle'), CFG.IDLE_MS);
}
/* ================================================================
SECTION 16 · KEYBOARD (arrows pan, +/- zoom, 0 reset, Backspace
history, J julia, S bookmark, ? help, Esc close)
================================================================ */
document.addEventListener('keydown', e => {
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
wake();
const P = (S.juliaOpen && document.activeElement === panes.julia.canvas) ? panes.julia : panes.main;
const v = (P === panes.julia) ? S.jView : S.view;
const step = v.scale * P.w * CFG.ARROW_PAN;
const k = e.key;
let handled = true;
switch (k) {
case 'ArrowLeft': v.re -= step; break;
case 'ArrowRight': v.re += step; break;
case 'ArrowUp': v.im += step; break;
case 'ArrowDown': v.im -= step; break;
case '+': case '=': zoomAtAnchor(P, CFG.KEY_ZOOM, P.w / 2, P.h / 2); break;
case '-': case '_': zoomAtAnchor(P, 1 / CFG.KEY_ZOOM, P.w / 2, P.h / 2); break;
case '0':
S.view = defaultMandView(); S.history = []; break;
case 'Backspace': case 'u': goBack(); break;
case 'j': case 'J':
if (S.juliaOpen) closeJulia();
else openJulia(S.view.re, S.view.im, true);
break;
case 's': case 'S':
document.getElementById('bmPanel').classList.add('open');
renderBookmarkList(); document.getElementById('bmName').focus();
break;
case '?': toggleHelp(true); break;
case 'Escape':
if (document.getElementById('help').classList.contains('open')) toggleHelp(false);
else if (document.getElementById('bmPanel').classList.contains('open'))
document.getElementById('bmPanel').classList.remove('open');
else closeJulia();
break;
default: handled = false;
}
if (handled) {
e.preventDefault();
if (k !== 'j' && k !== 'J' && k !== 's' && k !== 'S' && k !== '?' && k !== 'Escape') S.dirty = true;
commitView();
}
});
/* ================================================================
SECTION 17 · PERSISTENCE — localStorage bookmarks + shareable URL
hash state
================================================================ */
const LS_KEY = 'fractal-explorer-bookmarks-v1';
function loadBookmarks() {
try { return JSON.parse(localStorage.getItem(LS_KEY)) || []; } catch (e) { return []; }
}
function saveBookmarks(list) { try { localStorage.setItem(LS_KEY, JSON.stringify(list)); } catch (e) {} }
function currentBookmark() {
return { re: S.view.re, im: S.view.im, scale: S.view.scale,
palette: S.palette, offset: S.offset,
itersAuto: S.itersAuto, itersManual: S.itersManual,
set: S.juliaOpen ? 'julia' : 'mandelbrot', jRe: S.jRe, jIm: S.jIm };
}
function applyBookmark(b) {
S.view = clampView(makeView(b.re, b.im, b.scale));
S.palette = Math.min(PALETTES.length - 1, b.palette | 0);
S.offset = +b.offset || 0;
S.itersAuto = !!b.itersAuto;
S.itersManual = +b.itersManual || activeIters();
syncControls();
if (b.set === 'julia') openJulia(b.jRe, b.jIm, false); else closeJulia();
S.dirty = true;
writeHash();
}
function saveBookmark() {
const name = (document.getElementById('bmName').value || '').trim() ||
'view ' + (loadBookmarks().length + 1);
const list = loadBookmarks();
list.unshift(Object.assign({ name: name, t: Date.now() }, currentBookmark()));
if (list.length > 24) list.length = 24;
saveBookmarks(list);
renderBookmarkList();
toast('Saved “' + name + '”');
}
function renderBookmarkList() {
const list = loadBookmarks(), box = document.getElementById('bmList');
box.textContent = '';
if (!list.length) {
const d = document.createElement('div');
d.className = 'bmempty';
d.textContent = 'No bookmarks yet — press S to save the current view.';
box.appendChild(d);
return;
}
list.forEach((b, i) => {
const row = document.createElement('div'); row.className = 'bmrow';
const nm = document.createElement('span'); nm.className = 'nm';
nm.textContent = b.name + ' (' + fmtMag(1 / b.scale) + (b.set === 'julia' ? ' · Julia' : '') + ')';
nm.title = 'Restore this view';
nm.onclick = () => { applyBookmark(b); toast('Restored “' + b.name + '”'); };
const del = document.createElement('button');
del.textContent = '✕'; del.title = 'Delete bookmark';
del.onclick = () => { list.splice(i, 1); saveBookmarks(list); renderBookmarkList(); };
row.appendChild(nm); row.appendChild(del);
box.appendChild(row);
});
}
/* --- URL hash state (shareable links) --- */
function hashEncode() {
const b = currentBookmark();
const h = { re: b.re, im: b.im, s: b.scale, p: b.palette, o: Math.round(b.offset * 1000) / 1000,
ia: S.itersAuto ? 1 : 0, imn: b.itersManual,
set: S.juliaOpen ? 'j' : 'm' };
if (S.juliaOpen) { h.jr = b.jRe; h.ji = b.jIm; }
return '#' + Object.keys(h).map(k => k + '=' + (typeof h[k] === 'string' ? h[k] : +h[k].toPrecision(16))).join('&');
}
function hashDecode(str) {
const b = {};
(str || '').replace(/^#/, '').split('&').forEach(kv => {
const i = kv.indexOf('='); if (i < 0) return;
const key = kv.slice(0, i), val = kv.slice(i + 1);
b[key] = (key === 'set') ? val : parseFloat(val);
});
return b;
}
function applyHash() {
const h = hashDecode(location.hash);
if (!isFinite(h.re) || !isFinite(h.s)) return;
S.view = clampView(makeView(h.re, h.im || 0, Math.max(1e-15, h.s)));
if (h.p !== undefined) S.palette = Math.min(PALETTES.length - 1, h.p | 0);
if (isFinite(h.o)) S.offset = h.o;
S.itersAuto = h.ia !== 0;
if (h.imn) S.itersManual = h.imn;
syncControls();
if (h.set === 'j') openJulia(isFinite(h.jr) ? h.jr : 0, isFinite(h.ji) ? h.ji : 0, false);
else closeJulia();
S.dirty = true;
}
function writeHash() {
try { history.replaceState(null, '', hashEncode()); } catch (e) {}
}
window.addEventListener('hashchange', applyHash);
/* ================================================================
SECTION 18 · CONTROLS / UI WIRING
================================================================ */
function syncControls() {
document.querySelectorAll('#palChips .swatch').forEach((el, i) =>
el.classList.toggle('on', i === S.palette));
document.getElementById('offSlider').value = S.offset;
document.getElementById('btnDrift').classList.toggle('on', S.drift);
document.getElementById('itersAuto').checked = S.itersAuto;
document.getElementById('itersSlider').disabled = S.itersAuto;
if (!S.itersAuto) document.getElementById('itersSlider').value = S.itersManual;
S._aiShown = 0;
refreshHud();
}
function buildControls() {
const box = document.getElementById('palChips');
PALETTES.forEach((p, i) => {
const b = document.createElement('button');
b.className = 'swatch' + (i === S.palette ? ' on' : '');
b.title = p.id + ' palette';
b.setAttribute('aria-label', p.id + ' palette');
const g = p.stops.map(st => 'rgb(' + st[1] + ',' + st[2] + ',' + st[3] + ') ' +
Math.round(st[0] * 100) + '%').join(',');
b.style.background = 'linear-gradient(135deg,' + g + ')';
b.onclick = () => { S.palette = i; S.dirty = true; syncControls(); wake(); };
box.appendChild(b);
});
document.getElementById('offSlider').addEventListener('input', e => {
S.offset = +e.target.value; S.dirty = true; wake();
});
document.getElementById('btnDrift').onclick = () => { S.drift = !S.drift; syncControls(); wake(); };
document.getElementById('itersAuto').addEventListener('change', e => {
S.itersAuto = e.target.checked;
if (!S.itersAuto) S.itersManual = activeIters();
syncControls(); S.dirty = true; wake();
});
document.getElementById('itersSlider').addEventListener('input', e => {
S.itersManual = +e.target.value; S.dirty = true; syncControls(); wake();
});
document.getElementById('btnBack').onclick = () => { goBack(); wake(); };
document.getElementById('btnReset').onclick = () => {
S.view = defaultMandView(); S.history = []; S.dirty = true; commitView(); wake();
};
document.getElementById('btnSave').onclick = () => {
document.getElementById('bmPanel').classList.toggle('open');
renderBookmarkList();
if (document.getElementById('bmPanel').classList.contains('open'))
document.getElementById('bmName').focus();
};
document.getElementById('btnViews').onclick = () => {
const p = document.getElementById('bmPanel');
p.classList.toggle('open');
if (p.classList.contains('open')) renderBookmarkList();
};
document.getElementById('btnHelp').onclick = () => toggleHelp(true);
document.getElementById('helpClose').onclick = () => toggleHelp(false);
document.getElementById('help').addEventListener('click', e => {
if (e.target.id === 'help') toggleHelp(false);
});
document.getElementById('jClose').onclick = () => closeJulia();
document.getElementById('jRecenter').onclick = () => {
S.jView = makeView(0, 0, 3.1 / Math.max(1, panes.julia.h)); S.dirty = true;
};
document.getElementById('bmName').addEventListener('keydown', e => {
if (e.key === 'Enter') saveBookmark();
});
}
function toggleHelp(open) {
document.getElementById('help').classList.toggle('open', open);
}
/* ================================================================
SECTION 19 · MAIN LOOP — FPS, palette drift (recolor-only), live
Julia preview (coarse, throttled, cancellable), render dispatch
================================================================ */
let lastT = performance.now(), frames = 0, fpsT = lastT, lastRenderSig = '';
function frame(t) {
requestAnimationFrame(frame);
const dt = Math.min(0.1, (t - lastT) / 1000); lastT = t;
frames++;
if (t - fpsT > 500) {
document.getElementById('hudFps').textContent = Math.round(frames * 1000 / (t - fpsT));
frames = 0; fpsT = t;
}
// palette drift — colorize pass only, iterations are never recomputed
if (S.drift) {
S.offset = (S.offset + CFG.DRIFT_SPEED * dt) % 1;
document.getElementById('offSlider').value = S.offset;
if (panes.main.renderedKey === renderKey() && panes.main.fullBuf) recolorAll(panes.main);
}
// live Julia preview of the c under the cursor (coarse, time-sliced)
if (S.juliaOpen && S.previewDue && S.hoverC) {
S.previewDue = false;
if (t - S._prevPreviewT > CFG.PREVIEW_INTERVAL_MS) {
S._prevPreviewT = t;
previewJulia(panes.julia, S.hoverC.re, S.hoverC.im);
document.getElementById('jCval').textContent = 'c = ' + fmtC(S.hoverC.re, S.hoverC.im);
}
}
// dirty → (re)start progressive render; the old job is cancelled in place
if (S.dirty) {
S.dirty = false;
const sig = renderKey();
if (sig !== lastRenderSig || !S._everRendered) {
lastRenderSig = sig;
S._everRendered = true;
renderMainPass();
}
}
}
function renderMainPass() {
document.getElementById('hudRender').textContent = 'rendering';
const P = panes.main;
beginRender(P, {
view: S.view, cRe: S.view.re, cIm: S.view.im, julia: false,
maxIt: activeIters(), pass: 0, passes: CFG.PROGRESSIVE_PASSES,
});
if (S.juliaOpen) {
const PJ = panes.julia;
beginRender(PJ, {
view: S.jView, cRe: S.jRe, cIm: S.jIm, julia: true,
maxIt: Math.min(activeIters(), 1500), pass: 0, passes: CFG.PROGRESSIVE_PASSES,
});
}
}
/* coarse live preview: 1/4-resolution grid, time-sliced, scaled up */
function previewJulia(P, cRe, cIm) {
P._prevJob = (P._prevJob || 0) + 1;
const job = P._prevJob;
const tw = Math.max(16, P.w >> 2), th = Math.max(16, P.h >> 2);
const tmp = P._prev || (P._prev = document.createElement('canvas'));
tmp.width = tw; tmp.height = th;
const tctx = tmp.getContext('2d');
const timg = tctx.createImageData(tw, th);
const t32 = new Uint32Array(timg.data.buffer);
const buf = new Float32Array(tw * th);
const v = S.jView, s = v.scale;
const baseRe = v.re - (tw / 2) * s, baseIm = v.im + (th / 2) * s;
let row = 0;
function step() {
if (P._prevJob !== job) return; // superseded
const t0 = performance.now();
while (row < th && performance.now() - t0 < 8) {
for (let c = 0; c < tw; c++)
buf[row * tw + c] = fractalPixel(baseRe + c * s, baseIm - row * s, cRe, cIm,
CFG.PREVIEW_ITERS, true);
row++;
}
tileColorize(t32, buf, 0, S.offset * CFG.PALETTE_CYCLE, CFG.PREVIEW_ITERS);
tctx.putImageData(timg, 0, 0);
P.ctx.imageSmoothingEnabled = true;
P.ctx.drawImage(tmp, 0, 0, tw, th, 0, 0, P.w, P.h);
if (row < th) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
/* ================================================================
SECTION 20 · BOOT + DEBUG INTERFACE
window.fractal:
.iterationsAt(re, im) — Mandelbrot orbit count at c (cap 3000;
result >= 2999.5 means interior)
.setView({re, im, scale}) — scale = pixels per world unit
.getView() — {re, im, scale, iters, mag}
.setJulia(re, im) — set Julia c and re-render
.render() — force a render now
.cfg — the tunable constants block
================================================================ */
function boot() {
setupPane('main', document.getElementById('mainCanvas'), document.getElementById('mainOvl'));
setupPane('julia', document.getElementById('juliaCanvas'), document.getElementById('juliaOvl'));
S.view = defaultMandView();
bindCanvas(panes.main);
bindCanvas(panes.julia);
buildControls();
syncControls();
initWorkers();
renderBookmarkList();
if (location.hash.length > 1 && /re=/.test(location.hash)) applyHash();
wake();
requestAnimationFrame(frame);
window.fractal = {
iterationsAt: iterationsAt,
setView: setView,
getView: () => ({ re: S.view.re, im: S.view.im, scale: S.view.scale,
iters: activeIters(), mag: 1 / S.view.scale }),
setJulia: (re, im) => setJuliaC(re, im),
render: () => renderMainPass(),
cfg: CFG,
};
}
window.addEventListener('resize', () => {
resizePane('main');
if (S.juliaOpen) resizePane('julia');
});
boot();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"356effa2-3f50-4dc7-8a81-82e491125655","tokensIn":1961442,"tokensOut":165130,"tokensTotal":2126572,"cost":0,"turns":28,"toolCalls":34,"failedToolCalls":5,"timestamp":"2026-08-16T19:04:25.611Z"} -->