← Fractal Explorer results

Fractal Explorer

Qwen 3.8 27B GGUF · full

Initial view of Fractal Explorer

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 &amp; 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 &amp; 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"} -->