← Fractal Explorer results

Fractal Explorer

Deepseek v4.1 Flash · 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 74,134 bytes · SHA-256 6d050879dfed
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Fractal Explorer — Mandelbrot &amp; Julia</title>
<style>
  /* ============================================================
     SECTION: STYLES — layout reset, HUD chrome, overlays
     ============================================================ */
  :root {
    --hud-bg: rgba(12, 14, 22, 0.55);
    --hud-bg-solid: rgba(12, 14, 22, 0.82);
    --hud-border: rgba(255, 255, 255, 0.14);
    --hud-text: rgba(238, 242, 255, 0.92);
    --hud-dim: rgba(200, 208, 232, 0.62);
    --accent: #6ee7ff;
    --accent-2: #b48cff;
    --radius: 10px;
    --font: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
  }
  * { box-sizing: border-box; }
  html, body {
    margin: 0; padding: 0; width: 100%; height: 100%;
    background: #05060a; overflow: hidden;
    font-family: var(--font);
    color: var(--hud-text);
    -webkit-font-smoothing: antialiased;
    touch-action: none;
  }
  #stage { position: fixed; inset: 0; }
  canvas {
    position: absolute; inset: 0;
    width: 100%; height: 100%;
    display: block;
    cursor: crosshair;
    image-rendering: auto;
  }
  #glCanvas { z-index: 1; }
  #fxCanvas { z-index: 2; pointer-events: none; }

  /* ---------- HUD ---------- */
  #hud {
    position: fixed; z-index: 10;
    top: 14px; left: 14px; right: 14px;
    display: flex; align-items: flex-start; justify-content: space-between;
    gap: 12px; pointer-events: none;
    transition: opacity .45s ease, transform .45s ease;
  }
  #hud.idle { opacity: 0; transform: translateY(-6px); }
  .panel {
    pointer-events: auto;
    background: var(--hud-bg);
    border: 1px solid var(--hud-border);
    border-radius: var(--radius);
    backdrop-filter: blur(14px) saturate(150%);
    -webkit-backdrop-filter: blur(14px) saturate(150%);
    box-shadow: 0 8px 30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.06);
    padding: 10px 12px;
  }
  .stat-grid {
    display: grid; grid-template-columns: auto auto; gap: 3px 14px;
    font-size: 11.5px; line-height: 1.5;
  }
  .stat-grid .k { color: var(--hud-dim); letter-spacing: .04em; text-transform: uppercase; font-size: 9.5px; align-self: center;}
  .stat-grid .v { color: var(--hud-text); font-variant-numeric: tabular-nums; text-align: right; }
  .stat-grid .v.accent { color: var(--accent); }

  .stack { display: flex; flex-direction: column; gap: 10px; align-items: flex-end; }
  .row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }

  button, .chip {
    pointer-events: auto;
    font-family: var(--font);
    font-size: 11px;
    letter-spacing: .03em;
    color: var(--hud-text);
    background: rgba(255,255,255,.055);
    border: 1px solid var(--hud-border);
    border-radius: 8px;
    padding: 6px 10px;
    cursor: pointer;
    transition: background .15s ease, border-color .15s ease, transform .1s ease, color .15s;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
  }
  button:hover, .chip:hover { background: rgba(255,255,255,.12); border-color: rgba(255,255,255,.3); }
  button:active { transform: translateY(1px) scale(.985); }
  button:focus-visible, input:focus-visible, .chip:focus-visible {
    outline: 2px solid var(--accent); outline-offset: 1px;
  }
  button.primary { background: rgba(110,231,255,.16); border-color: rgba(110,231,255,.45); color: #d9f6ff; }
  button.primary:hover { background: rgba(110,231,255,.28); }
  button.on { background: rgba(180,140,255,.26); border-color: rgba(180,140,255,.6); color: #f0e9ff; }

  .ctl { display: flex; align-items: center; gap: 8px; font-size: 10px; color: var(--hud-dim); }
  .ctl label { text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; min-width: 62px; }
  input[type=range] {
    -webkit-appearance: none; appearance: none;
    width: 122px; height: 18px; background: transparent; cursor: pointer; margin: 0;
  }
  input[type=range]::-webkit-slider-runnable-track {
    height: 3px; border-radius: 3px;
    background: linear-gradient(90deg, var(--accent), var(--accent-2));
    opacity: .75;
  }
  input[type=range]::-webkit-slider-thumb {
    -webkit-appearance: none; width: 12px; height: 12px; margin-top: -4.5px;
    border-radius: 50%; background: #eaf6ff; border: 1px solid rgba(0,0,0,.4);
    box-shadow: 0 0 8px rgba(110,231,255,.7);
  }
  input[type=range]::-moz-range-track { height: 3px; border-radius: 3px; background: rgba(255,255,255,.25); }
  input[type=range]::-moz-range-thumb { width: 12px; height: 12px; border: none; border-radius: 50%; background: #eaf6ff; }
  input[type=range].wide { width: 150px; }

  /* palette swatches */
  #palettes { display: flex; gap: 6px; }
  .swatch {
    width: 42px; height: 32px; border-radius: 8px; cursor: pointer;
    border: 1px solid rgba(255,255,255,.18);
    position: relative; overflow: hidden;
    box-shadow: inset 0 0 0 1px rgba(0,0,0,.35);
    transition: transform .12s ease, box-shadow .15s ease;
  }
  .swatch:hover { transform: translateY(-2px); }
  .swatch.sel { box-shadow: 0 0 0 2px var(--accent), 0 0 14px rgba(110,231,255,.7); transform: translateY(-2px); }
  .swatch span {
    position: absolute; bottom: 1px; left: 0; right: 0; text-align: center;
    font-size: 8px; letter-spacing: .05em; color: rgba(255,255,255,.95);
    text-shadow: 0 1px 3px rgba(0,0,0,.95); text-transform: uppercase;
  }

  #juliaPanel .title { font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--hud-dim); margin-bottom: 6px; }
  #juliaCanvasWrap { position: relative; width: 168px; height: 168px; border-radius: 8px; overflow: hidden; border: 1px solid var(--hud-border); background: #000; }
  #juliaCanvas { position: absolute; inset: 0; width: 100%; height: 100%; cursor: crosshair; }
  #juliaPanel input[type=text] {
    width: 168px; margin-top: 6px; background: rgba(0,0,0,.4); border: 1px solid var(--hud-border);
    color: var(--hud-text); border-radius: 6px; padding: 4px 6px; font-family: var(--font); font-size: 11px;
  }
  #juliaPanel.hidden { display: none; }

  /* ---------- bottom bar ---------- */
  #bottom {
    position: fixed; z-index: 10; left: 50%; bottom: 14px; transform: translateX(-50%);
    display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center;
    max-width: calc(100% - 20px);
    transition: opacity .45s ease, transform .45s ease;
  }
  #bottom.idle { opacity: 0; transform: translateX(-50%) translateY(8px); pointer-events: none; }

  /* ---------- bookmarks dropdown ---------- */
  #bookmarks {
    position: fixed; z-index: 11; top: 14px; right: 14px;
    width: 268px; max-height: 62vh; overflow: auto;
    display: none; flex-direction: column; gap: 4px;
  }
  #bookmarks.show { display: flex; }
  #bookmarks .bm {
    display: flex; align-items: center; gap: 6px; justify-content: space-between;
    padding: 5px 7px; border-radius: 7px; background: rgba(255,255,255,.045); font-size: 11px;
  }
  #bookmarks .bm .nm { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #bookmarks .bm .meta { font-size: 9px; color: var(--hud-dim); }
  #bookmarks .bm button { padding: 3px 7px; font-size: 10px; }
  #bookmarks h3 { margin: 2px 0 6px; font-size: 10.5px; letter-spacing: .1em; text-transform: uppercase; color: var(--hud-dim); font-weight: 500;}
  #bookmarks .empty { font-size: 11px; color: var(--hud-dim); padding: 4px 2px;}
  #bmName {
    background: rgba(0,0,0,.4); border: 1px solid var(--hud-border); border-radius: 7px;
    color: var(--hud-text); font-family: var(--font); font-size: 11px; padding: 5px 7px; width: 100%;
  }

  /* ---------- help / toast ---------- */
  #help {
    position: fixed; z-index: 12; inset: 0; display: none;
    align-items: center; justify-content: center; background: rgba(4,5,10,.72);
    backdrop-filter: blur(6px);
  }
  #help.show { display: flex; }
  #help .card { max-width: 430px; padding: 22px 24px; }
  #help h2 { margin: 0 0 10px; font-size: 15px; letter-spacing: .04em; }
  #help table { border-collapse: collapse; font-size: 12px; width: 100%; }
  #help td { padding: 3px 0; color: var(--hud-dim); }
  #help td:first-child { color: var(--hud-text); white-space: nowrap; padding-right: 14px; }
  #help kbd {
    font-family: var(--font); font-size: 10.5px; background: rgba(255,255,255,.1);
    border: 1px solid var(--hud-border); border-radius: 4px; padding: 1px 5px; color: var(--hud-text);
  }
  #toast {
    position: fixed; z-index: 14; left: 50%; bottom: 68px; transform: translateX(-50%) translateY(10px);
    background: var(--hud-bg-solid); border: 1px solid var(--hud-border); border-radius: 9px;
    padding: 8px 14px; font-size: 11.5px; opacity: 0; transition: opacity .3s, transform .3s;
    pointer-events: none; max-width: 80vw; text-align: center;
  }
  #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
  #progress {
    position: fixed; z-index: 9; left: 0; top: 0; height: 2px; width: 0%;
    background: linear-gradient(90deg, var(--accent), var(--accent-2));
    transition: width .18s linear; box-shadow: 0 0 8px var(--accent);
  }
  @media (max-width: 760px) {
    #hud { flex-direction: column; }
    .stack { align-items: flex-start; }
    .row { justify-content: flex-start; }
    #bookmarks { width: calc(100% - 28px); }
    #juliaCanvasWrap { width: 120px; height: 120px; }
    #juliaPanel input[type=text] { width: 120px; }
  }
</style>
</head>
<body>
<div id="progress"></div>
<div id="stage">
  <canvas id="glCanvas"></canvas>
  <canvas id="fxCanvas"></canvas>
</div>

<!-- ============ HUD ============ -->
<div id="hud">
  <div class="panel" id="statPanel">
    <div class="stat-grid">
      <div class="k">Zoom</div><div class="v accent" id="sZoom">×1.00</div>
      <div class="k">Center</div><div class="v" id="sCenter">—</div>
      <div class="k">Iter</div><div class="v" id="sIter">—</div>
      <div class="k">Render</div><div class="v" id="sTime">—</div>
      <div class="k">FPS</div><div class="v" id="sFps">—</div>
      <div class="k">Mode</div><div class="v" id="sMode">Mandelbrot</div>
    </div>
  </div>

  <div class="stack">
    <div class="panel">
      <div class="ctl" style="margin-bottom:8px">
        <label>Palette</label>
        <div id="palettes"></div>
      </div>
      <div class="ctl"><label title="Extra iterations added on top of the zoom-scaled base">Iter boost</label><input type="range" id="iterBoost" min="0" max="4000" step="10" value="0"></div>
      <div class="ctl"><label title="Shifts palette phase without recomputing iterations">Cycle</label><input type="range" id="cycle" min="0" max="100" step="1" value="0"></div>
      <div class="ctl"><label title="Mandelbrot set: complex power exponent (2.00 = classic)">Power</label><input type="range" id="power" min="20" max="600" step="1" value="100"></div>
      <div class="ctl"><label title="Nonlinearity of palette spread">Spread</label><input type="range" id="spread" min="10" max="400" step="1" value="100"></div>
    </div>

    <div class="panel" id="juliaPanel">
      <div class="title">Julia preview · <span id="cVal">c = 0 + 0i</span></div>
      <div id="juliaCanvasWrap"><canvas id="juliaCanvas" width="220" height="220"></canvas></div>
      <input type="text" id="cInput" spellcheck="false" aria-label="Julia c value">
      <div class="row" style="margin-top:6px">
        <button id="openJulia" class="primary" title="Swap the main view to this Julia set (J)">Open Julia in main</button>
      </div>
    </div>
  </div>
</div>

<div id="bottom">
  <button id="back" title="Zoom out one step (Backspace / Z)">◀ Back</button>
  <button id="reset" title="Reset to full Mandelbrot view (R)">Reset</button>
  <button id="mandel" title="Switch main view to Mandelbrot (M)">Mandelbrot</button>
  <button id="julia" title="Show Julia panel (J)">Julia…</button>
  <button id="bmBtn" title="Bookmark views">★ Bookmarks</button>
  <button id="share" title="Copy a shareable link">⧉ Link</button>
  <button id="helpBtn" title="Keyboard shortcuts (?)">?</button>
</div>

<div id="bookmarks" class="panel">
  <h3>Bookmarks</h3>
  <div style="display:flex;gap:6px">
    <input id="bmName" placeholder="name this view…" spellcheck="false">
    <button id="bmSave" class="primary">Save</button>
  </div>
  <div id="bmList" style="display:flex;flex-direction:column;gap:4px;margin-top:8px"></div>
</div>

<div id="help">
  <div class="card panel">
    <h2>Fractal Explorer</h2>
    <table>
      <tr><td><kbd>Drag</kbd></td><td>Rectangle zoom (release to apply)</td></tr>
      <tr><td><kbd>Wheel</kbd></td><td>Zoom toward cursor</td></tr>
      <tr><td><kbd>Right-drag</kbd> / <kbd>Shift</kbd>+drag</td><td>Pan (also two-finger)</td></tr>
      <tr><td><kbd>Click</kbd></td><td>Julia preview at that point</td></tr>
      <tr><td><kbd>Arrows</kbd></td><td>Pan · <kbd>+</kbd>/<kbd>−</kbd> zoom</td></tr>
      <tr><td><kbd>J</kbd> / <kbd>M</kbd></td><td>Julia / Mandelbrot main view</td></tr>
      <tr><td><kbd>R</kbd> / <kbd>Bksp</kbd></td><td>Reset / back</td></tr>
      <tr><td><kbd>P</kbd></td><td>Cycle palette</td></tr>
      <tr><td><kbd>[</kbd> <kbd>]</kbd></td><td>Palette cycle offset</td></tr>
      <tr><td><kbd>S</kbd> / <kbd>?</kbd></td><td>Save bookmark / this help</td></tr>
    </table>
    <div class="row" style="margin-top:14px">
      <button id="helpClose" class="primary">Got it</button>
    </div>
  </div>
</div>
<div id="toast"></div>

<script>
"use strict";
/* ============================================================================
   FRACTAL EXPLORER
   Single-file, dependency-free Mandelbrot & Julia explorer.

   Sections
     A. Tunable constants block
     B. Color palettes & colour math
     C. State + precision model
     D. Core escape-time loop (allocation-free)
     E. Renderer: progressive multi-pass, cancellation, workers
     F. Canvas presentation for main view + Julia inset
     G. Interaction: drag-zoom, pan, wheel, keyboard, touch
     H. HUD, bookmarks (localStorage), URL hash
     I. Debug interface on window.fractal
   ========================================================================== */

/* ============================ A. CONSTANTS ============================== */
const CFG = {
  /* Iteration limits: maxIter = BASE + SCALE * log10(zoom) (clamped). */
  ITER_BASE: 100,
  ITER_SCALE: 185,
  ITER_MIN: 64,
  ITER_MAX: 12000,
  ITER_BOOST_MAX: 4000,          // manual override slider range

  /* Hard precision ceiling. Doubles lose useful mantissa bits past ~1e13. */
  MAX_ZOOM: 1e13,
  WARN_ZOOM: 1e12,

  /* Navigation */
  WHEEL_RATE: 0.0018,            // exp zoom per wheel delta unit
  WHEEL_LINE: 1.0,
  ZOOM_STEP: 1.6,                // keyboard +/- step
  PAN_FRACTION: 0.12,            // arrow-key pan (fraction of view width)

  /* Progressive render passes: [scaleDivisor (px block), iterationFraction].
     Pass 0 is fast and blocky; later passes refine to 1:1. */
  PASSES: [
    { block: 8, iterFrac: 0.32 },
    { block: 4, iterFrac: 0.55 },
    { block: 1, iterFrac: 1.00 },
  ],

  /* Workers */
  USE_WORKERS: true,
  WORKER_POOL: Math.max(1, Math.min(8, (navigator.hardwareConcurrency || 4) - 1)),

  /* Julia inset */
  JULIA_ITER: 260,
  JULIA_SIZE: 220,

  /* Interaction */
  DRAG_THRESHOLD: 6,             // px before a drag becomes a rectangle zoom
  HUD_IDLE_MS: 2600,
  MOMENTUM: 0.90,
  MOMENTUM_MIN: 0.02,

  /* Display */
  ZOOM_FOR_FULL_VIEW: 0.7,       // view span for the default framing
};

const DEFAULT_CENTER = { re: -0.6, im: 0.0 };
const DEFAULT_SCALE = 3.2;       // vertical span of the view in complex units

/* ============================ B. PALETTES =============================== */
/* Each palette: array of stops [t, r, g, b] with t in [0,1], plus cyclic flag.
   Interpolation is done in a perceptual-ish way: linear on a gamma-adjusted
   square root, which avoids muddy midpoints. */
function mkPalette(stops, cyclic) { return { stops, cyclic: !!cyclic }; }

const PALETTES = {
  Fire: mkPalette([
    [0.00,   0,   0,   0],
    [0.14,  36,   0,  62],
    [0.34, 128,   8,  70],
    [0.52, 214,  44,  28],
    [0.70, 252, 132,  16],
    [0.86, 255, 214,  92],
    [1.00, 255, 255, 224],
  ], false),
  Ocean: mkPalette([
    [0.00,   1,   4,  14],
    [0.16,   6,  32,  70],
    [0.38,   8,  86, 128],
    [0.58,  18, 158, 166],
    [0.76, 108, 226, 214],
    [0.90, 208, 250, 246],
    [1.00, 255, 255, 255],
  ], false),
  Monochrome: mkPalette([
    [0.00,   0,   0,   0],
    [0.22,  22,  22,  26],
    [0.48,  92,  92, 100],
    [0.70, 172, 172, 182],
    [0.88, 232, 232, 240],
    [1.00, 255, 255, 255],
  ], false),
  Psychedelic: mkPalette([
    [0.000,  20,   0,  48],
    [0.166, 120,  30, 200],
    [0.333, 240,  40, 160],
    [0.500, 255, 190,  40],
    [0.666,  60, 230, 130],
    [0.833,  30, 130, 240],
    [1.000,  20,   0,  48],
  ], true),
  Aurora: mkPalette([
    [0.00,   3,   6,  18],
    [0.18,  12,  54,  74],
    [0.40,  22, 140, 128],
    [0.58, 120, 232, 170],
    [0.74, 228, 252, 214],
    [0.88, 214, 168, 255],
    [1.00,  90,  60, 170],
  ], false),
  Ember: mkPalette([
    [0.00,   0,   0,   0],
    [0.20,  46,  10,   6],
    [0.42, 130,  26,  10],
    [0.62, 214,  80,  14],
    [0.80, 250, 170,  60],
    [0.93, 255, 236, 190],
    [1.00, 120,  60, 180],
  ], false),
};
const PALETTE_NAMES = Object.keys(PALETTES);
const PALETTE_SHORT = {
  Fire: "Fire", Ocean: "Ocean", Monochrome: "Mono",
  Psychedelic: "Psych", Aurora: "Aurora", Ember: "Ember",
};

/* Palette gradients are baked into a Uint32Array LUT (little-endian ABGR,
   which is exactly what an ImageData/ArrayBuffer byte view expects), so the
   colorize pass is a pure table lookup — no float color math per pixel. */
const LUT_SIZE = 1024;
const PAL_LUT = {};   // name -> Uint32Array(LUT_SIZE)

function buildPalettes() {
  for (const name of PALETTE_NAMES) {
    const p = PALETTES[name];
    const lut = new Uint32Array(LUT_SIZE);
    const s = p.stops;
    for (let i = 0; i < LUT_SIZE; i++) {
      const t = i / (LUT_SIZE - 1);
      // find bracketing stops
      let k = 0;
      while (k < s.length - 2 && s[k + 1][0] < t) k++;
      const a = s[k], b = s[k + 1];
      const span = (b[0] - a[0]) || 1;
      let f = (t - a[0]) / span;
      if (f < 0) f = 0; else if (f > 1) f = 1;
      f = f * f * (3 - 2 * f);                    // smoothstep between stops
      const r = (a[1] + (b[1] - a[1]) * f) | 0;
      const g = (a[2] + (b[2] - a[2]) * f) | 0;
      const bl = (a[3] + (b[3] - a[3]) * f) | 0;
      lut[i] = (255 << 24) | (bl << 16) | (g << 8) | r;
    }
    PAL_LUT[name] = lut;
  }
}
buildPalettes();

/* COLOR: mapping constants (colorize pass only — never triggers re-iterating) */
const COLOR = {
  SQRT_RATE: 0.085,   // cycles per sqrt(iteration)
  SPREAD_DEF: 1.0,
};

/* ============================ C. STATE ================================= */
/* Precision model
   ---------------
   All coordinates are IEEE-754 doubles. The reachable resolution is
   ulp(|c|) ~ 2^-52 ~ 2.2e-16. With the default vertical span of 3.2 the
   *minimum* usable span is therefore ~ (3.2 * 1e-13) before neighbouring
   pixels start collapsing onto identical doubles — i.e. about ×1e13
   magnification. MAX_ZOOM enforces exactly that; WARN_ZOOM warns earlier. */
const PRECISION_DECADES = 13;
CFG.MAX_ZOOM = Math.pow(10, PRECISION_DECADES);
CFG.WARN_ZOOM = Math.pow(10, PRECISION_DECADES - 1);

const state = {
  mode: "mandelbrot",              // "mandelbrot" | "julia"
  center: { re: DEFAULT_CENTER.re, im: DEFAULT_CENTER.im },
  scale: DEFAULT_SCALE,            // vertical span, complex units
  jc: { re: -0.7885, im: 0.156 },  // Julia parameter
  palette: "Fire",
  iterBoost: 0,
  cycle: 0,                        // palette phase offset in cycles
  spread: 1.0,
  power: 2.0,
  smooth: true,
  autoIter: true,
};

const view = {                     // exact view currently ON SCREEN
  mode: "mandelbrot",
  cx: DEFAULT_CENTER.re, cy: DEFAULT_CENTER.im,
  span: DEFAULT_SCALE,
  jcRe: 0, jcIm: 0,
  maxIter: CFG.ITER_BASE,
  W: 0, H: 0, dpr: 1,
};

const history = [];                // zoom history stack (committed views)

/* Canvas / buffer bookkeeping ------------------------------------------- */
const glCanvas = document.getElementById("glCanvas");
const fxCanvas = document.getElementById("fxCanvas");
const gctx = glCanvas.getContext("2d", { alpha: false, desynchronized: true });
const fctx = fxCanvas.getContext("2d");
let buf = null;                    // { W, H, img: ImageData, u32: Uint32Array, esc: Float32Array }
let pending = null;                // candidate view while interacting (not yet rendered)

/* ---------------------- geometry helpers ------------------------------- */
function zoomOf(s) { return DEFAULT_SCALE / s; }
function viewW() { return view.span * view.W / view.H; }

/* Screen <-> complex mapping.
   Screen coordinates are CONTINUOUS device-pixel coordinates in [0,W]x[0,H]:
   the geometric centre of the canvas is (W/2, H/2) and the centre of pixel
   index i is at i+0.5. The renderer samples pixel i at (i+0.5) with the same
   affine constants, so a selection rectangle frames exactly what it covers. */
function pixelToComplex(px, py, W, H, cx, cy, span) {
  const vw = span * W / H, vh = span;
  return {
    re: cx + (px / W - 0.5) * vw,
    im: cy + (0.5 - py / H) * vh,
  };
}
function complexToPixel(re, im, W, H, cx, cy, span) {
  const vw = span * W / H, vh = span;
  return {
    x: ((re - cx) / vw + 0.5) * W,
    y: (0.5 - (im - cy) / vh) * H,
  };
}

/* Iteration limit scales with zoom depth; manual slider adds a boost. */
function iterationsFor(span) {
  const z = Math.max(1, zoomOf(span));
  let n = CFG.ITER_BASE + CFG.ITER_SCALE * Math.log10(z) + state.iterBoost;
  if (n < CFG.ITER_MIN) n = CFG.ITER_MIN;
  if (n > CFG.ITER_MAX) n = CFG.ITER_MAX;
  return Math.round(n);
}

/* ===================== D. CORE ITERATION LOOP ========================== */
/* Allocation-free escape-time integration. Everything below writes into
   caller-supplied buffers; no object / array literals are created inside the
   per-pixel hot path. `block` samples one point per block×block tile and
   replicates it, so low-resolution passes cost block^2 times less fill-in
   while still covering the whole canvas (no holes, no ghost tiles). */

const BAIL2 = 4.0;                 // |z| > 2  <=>  |z|^2 > 4
const LOG2 = Math.log(2);
let ES_LAST_N = 0;                 // integer escape iteration of the last escapePoint call

/* Single-point escape with smooth (continuous) iteration count.
   Returns the float iteration value, or -1 for "interior" (never escaped). */
function escapePoint(cRe, cIm, maxIter, julia, jRe, jIm, pow2, usePow) {
  let zr, zi, cr, ci;
  if (julia) { zr = cRe; zi = cIm; cr = jRe; ci = jIm; }
  else { zr = 0; zi = 0; cr = cRe; ci = cIm; }
  let m = 0, n = 0;
  if (!usePow) {
    for (; n < maxIter; n++) {
      const zr2 = zr * zr, zi2 = zi * zi;
      if (zr2 + zi2 > BAIL2) break;
      zi = 2 * zr * zi + ci;
      zr = zr2 - zi2 + cr;
    }
    if (n >= maxIter) { ES_LAST_N = n; return -1; }
    m = zr * zr + zi * zi;
  } else {
    const p = state.power, half = p * 0.5;
    for (; n < maxIter; n++) {
      const r2 = zr * zr + zi * zi;
      if (r2 > BAIL2) break;
      if (r2 < 1e-300) { zr = cr; zi = ci; continue; }
      const r = Math.sqrt(r2);
      const th = Math.atan2(zi, zr);
      const nr = Math.pow(r, p);
      const na = half * th;
      zr = nr * Math.cos(na) + cr;
      zi = nr * Math.sin(na) + ci;
    }
    if (n >= maxIter) { ES_LAST_N = n; return -1; }
    m = zr * zr + zi * zi;
  }
  ES_LAST_N = n;
  if (m <= BAIL2) m = BAIL2 + 1e-9;
  // smooth: mu = n + 1 - log2(log|z|)
  const lg = 0.5 * Math.log(m) / LOG2;          // log2|z|
  let mu = n + 1 - Math.log(lg > 1e-12 ? lg : 1e-12) / LOG2;
  if (!(mu >= 0)) mu = n;
  return mu;
}

/* Build a render-parameter snapshot (copied once per pass, not per pixel). */
function makeParams(W, H, cx, cy, span, maxIter, mode, jcRe, jcIm) {
  return {
    W, H, cx, cy, span, maxIter,
    julia: mode === "julia" ? 1 : 0,
    jcRe, jcIm,
    usePow: state.power !== 2.0,
    // precomputed affine terms: re = aRe + px*stepRe ; im = aIm - py*stepIm
    stepRe: span / H,
    stepIm: span / H,
    aRe: cx - (span * W / H) * 0.5,
    aIm: cy + span * 0.5,
  };
}

/* Render rows [y0, y1) of a pass into out (length (y1-y0)*W).
   `out` may be the destination subarray of the full escape buffer. */
function renderRows(out, outOff, p, y0, y1, block) {
  const W = p.W, span = p.span;
  const blockF = block;
  const maxIter = p.maxIter;
  const julia = p.julia, jcRe = p.jcRe, jcIm = p.jcIm;
  const usePow = p.usePow, power = state.power;
  const half = power * 0.5;

  for (let y = y0; y < y1; y += block) {
    // sample at the block centre for a better low-res approximation
    const im = p.aIm - (y + blockF * 0.5) * p.stepIm;
    const rowBase = (y - y0) * W;
    for (let x = 0; x < W; x += block) {
      const re = p.aRe + (x + blockF * 0.5) * p.stepRe;
      // ---- inline escape loop (duplicated for speed; no per-pixel alloc) ----
      let zr, zi, cr, ci;
      if (julia) { zr = re; zi = im; cr = jcRe; ci = jcIm; }
      else { zr = 0; zi = 0; cr = re; ci = im; }
      let n = 0, m = 0;
      if (!usePow) {
        for (; n < maxIter; n++) {
          const zr2 = zr * zr, zi2 = zi * zi;
          if (zr2 + zi2 > BAIL2) break;
          zi = 2 * zr * zi + ci;
          zr = zr2 - zi2 + cr;
        }
      } else {
        for (; n < maxIter; n++) {
          const r2 = zr * zr + zi * zi;
          if (r2 > BAIL2) break;
          if (r2 < 1e-300) { zr = cr; zi = ci; continue; }
          const r = Math.sqrt(r2), th = Math.atan2(zi, zr);
          const nr = Math.pow(r, power), na = half * th;
          zr = nr * Math.cos(na) + cr;
          zi = nr * Math.sin(na) + ci;
        }
      }
      let val;
      if (n >= maxIter) {
        val = -1;
      } else {
        m = zr * zr + zi * zi;
        if (m <= BAIL2) m = BAIL2 + 1e-9;
        const lg = 0.5 * Math.log(m) / LOG2;
        val = n + 1 - Math.log(lg > 1e-12 ? lg : 1e-12) / LOG2;
        if (!(val >= 0)) val = n;
      }
      // fill the block×block tile
      const xEnd = Math.min(x + block, W);
      const yEnd = Math.min(y + block, y1);
      for (let yy = y; yy < yEnd; yy++) {
        const base = (yy - y0) * W;
        for (let xx = x; xx < xEnd; xx++) out[base + xx] = val;
      }
    }
  }
}

/* Colorize: escape floats -> pixels. Pure pass; changing palette / cycle /
   spread re-runs only this, never the iteration loop. */
function colorize(esc, W, H, u32, lut, cyclic, smooth, cycle, spread, maxIter) {
  const rate = COLOR.SQRT_RATE;
  const n = esc.length;
  if (!smooth) {
    /* quantized fallback: one flat colour per integer iteration (debug/compare) */
    for (let i = 0; i < n; i++) {
      const e = esc[i];
      if (e < 0) { u32[i] = 0xff000000; continue; }
      let u = (e | 0) * 0.031 + cycle;
      u -= Math.floor(u);
      u32[i] = lut[(u * (LUT_SIZE - 1)) | 0];
    }
    return;
  }
  for (let i = 0; i < n; i++) {
    const e = esc[i];
    if (e < 0) { u32[i] = 0xff000000; continue; }
    // f = sqrt(e) * rate + cycle   (sqrt keeps band width usable at any depth)
    const f = Math.sqrt(e) * rate + cycle;
    let u;
    if (cyclic) {
      u = f - Math.floor(f);                              // wrap (palette loops)
    } else {
      // smooth triangle: 0 -> 1 -> 0 across each cycle. Continuous everywhere,
      // so there is never a hard edge at a band boundary.
      const ph = f - Math.floor(f);
      u = 0.5 - 0.5 * Math.cos(ph * 6.283185307179586);
    }
    if (spread !== 1) u = Math.pow(u, spread);
    let idx = (u * (LUT_SIZE - 1)) | 0;
    if (idx < 0) idx = 0; else if (idx > LUT_SIZE - 1) idx = LUT_SIZE - 1;
    u32[i] = lut[idx];
  }
}

/* ======================= E. RENDERER / SCHEDULER ======================= */
/* Progressive, cancellable, time-sliced renderer.

   A "job" walks CFG.PASSES from coarse to fine. Each pass is split into
   horizontal bands; bands are either handed to Web Workers or computed inline
   under a per-frame time budget. Every pass fills the *whole* canvas (coarse
   blocks are replicated), so at no point are there holes or ghost tiles.
   `token` is the cancellation generation: bumping it abandons every in-flight
   band result without touching the framebuffer. */

const WORKER_SRC = `
"use strict";
const BAIL2 = 4.0, LOG2 = Math.log(2);
let P = null, power = 2.0, usePow = false;
function renderBand(y0, y1, block) {
  const W = P.W, maxIter = P.maxIter;
  const julia = P.julia, jcRe = P.jcRe, jcIm = P.jcIm;
  const half = power * 0.5;
  const rows = y1 - y0;
  const out = new Float32Array(rows * W);
  for (let y = y0; y < y1; y += block) {
    const im = P.aIm - (y + block * 0.5) * P.stepIm;
    for (let x = 0; x < W; x += block) {
      const re = P.aRe + (x + block * 0.5) * P.stepRe;
      let zr, zi, cr, ci;
      if (julia) { zr = re; zi = im; cr = jcRe; ci = jcIm; }
      else { zr = 0; zi = 0; cr = re; ci = im; }
      let n = 0, m = 0;
      if (!usePow) {
        for (; n < maxIter; n++) {
          const zr2 = zr * zr, zi2 = zi * zi;
          if (zr2 + zi2 > BAIL2) break;
          zi = 2 * zr * zi + ci;
          zr = zr2 - zi2 + cr;
        }
      } else {
        for (; n < maxIter; n++) {
          const r2 = zr * zr + zi * zi;
          if (r2 > BAIL2) break;
          if (r2 < 1e-300) { zr = cr; zi = ci; continue; }
          const r = Math.sqrt(r2), th = Math.atan2(zi, zr);
          const nr = Math.pow(r, power), na = half * th;
          zr = nr * Math.cos(na) + cr;
          zi = nr * Math.sin(na) + ci;
        }
      }
      let val;
      if (n >= maxIter) { val = -1; }
      else {
        m = zr * zr + zi * zi;
        if (m <= BAIL2) m = BAIL2 + 1e-9;
        const lg = 0.5 * Math.log(m) / LOG2;
        val = n + 1 - Math.log(lg > 1e-12 ? lg : 1e-12) / LOG2;
        if (!(val >= 0)) val = n;
      }
      const xEnd = Math.min(x + block, W), yEnd = Math.min(y + block, y1);
      for (let yy = y; yy < yEnd; yy++) {
        const base = (yy - y0) * W;
        for (let xx = x; xx < xEnd; xx++) out[base + xx] = val;
      }
    }
  }
  return out;
}
self.onmessage = function (ev) {
  const d = ev.data;
  if (d.cmd === "cfg") { P = d.p; power = d.power; usePow = d.usePow; return; }
  P = d.p; power = d.power; usePow = d.usePow;
  const band = renderBand(d.y0, d.y1, d.block);
  self.postMessage({ id: d.id, token: d.token, y0: d.y0, y1: d.y1, block: d.block, buf: band },
                   [band.buffer]);
};
`;

let workers = [];
function initWorkers() {
  if (!CFG.USE_WORKERS || typeof Worker === "undefined") return;
  try {
    const url = URL.createObjectURL(new Blob([WORKER_SRC], { type: "text/javascript" }));
    for (let i = 0; i < CFG.WORKER_POOL; i++) {
      const w = new Worker(url);
      w.busy = false;
      w.onmessage = onWorkerMessage;
      w.onerror = (e) => {
        // blob workers are blocked on some file:// origins — fall back inline
        if (e && e.preventDefault) e.preventDefault();
        w.dead = true;
        if (!FORCE_INLINE) fallbackInline();
      };
      workers.push(w);
    }
  } catch (e) {
    workers = [];
  }
}
let FORCE_INLINE = false;
initWorkers();

/* Called if the worker pool turns out to be unusable: abandon the pool and
   re-run the pass on the main thread. No partial state is left behind. */
function fallbackInline() {
  FORCE_INLINE = true;
  for (const w of workers) { w.dead = true; try { w.terminate(); } catch (e) {} }
  Renderer.inflight = 0;
  if (Renderer.active) beginPass(Renderer.passIdx);
}

const Renderer = {
  token: 0,
  active: false,
  esc: null,            // Float32Array(W*H) escape values, -1 = interior
  W: 0, H: 0,
  params: null,
  passIdx: 0,
  queue: [],            // pending band [y0,y1] for current pass
  inflight: 0,
  doneBands: 0,
  totalBands: 0,
  t0: 0,
  lastMs: 0,
  presented: 0,
};

function allocEsc(W, H) {
  if (!buf || buf.W !== W || buf.H !== H) {
    const img = gctx.createImageData(W, H);
    buf = { W, H, img, u32: new Uint32Array(img.data.buffer), esc: new Float32Array(W * H) };
  }
  Renderer.esc = buf.esc;
  Renderer.W = W; Renderer.H = H;
}

/* --- schedule a fresh render for the current `state` view ------------- */
function scheduleRender() {
  if (!buf) return;                 // not sized yet; resize() will call us
  const W = buf.W, H = buf.H;
  Renderer.token++;
  Renderer.passIdx = 0;
  Renderer.presented = 0;
  passesDone = 0;
  Renderer.t0 = performance.now();
  Renderer.lastProgress = Renderer.t0;
  Renderer.active = true;
  const maxIterFull = iterationsFor(state.scale);
  view.mode = state.mode;
  view.cx = state.center.re; view.cy = state.center.im;
  view.span = state.scale;
  view.jcRe = state.jc.re; view.jcIm = state.jc.im;
  view.maxIter = maxIterFull;
  beginPass(0);
}

function beginPass(idx) {
  const P = CFG.PASSES[idx];
  Renderer.passIdx = idx;
  const H = Renderer.H;
  const maxIter = Math.max(CFG.ITER_MIN,
    Math.round(iterationsFor(state.scale) * P.iterFrac));
  const p = makeParams(Renderer.W, H, state.center.re, state.center.im, state.scale,
                       maxIter, state.mode, state.jc.re, state.jc.im);
  Renderer.params = p;
  // fan the pass out into bands
  const block = P.block;
  const useWorkers = !FORCE_INLINE && workers.length > 0 && !workers.every(w => w.dead);
  const bandH = useWorkers
    ? Math.max(block * 2, Math.ceil(H / (workers.length * 3) / block) * block)
    : Math.max(block * 4, 24);
  Renderer.queue = [];
  for (let y = 0; y < H; y += bandH) {
    Renderer.queue.push([y, Math.min(y + bandH, H)]);
  }
  Renderer.inflight = 0;
  Renderer.doneBands = 0;
  Renderer.totalBands = Renderer.queue.length;
  pumpRender();
}

function onWorkerMessage(ev) {
  const d = ev.data;
  const w = ev.target;
  if (w) { w.busy = false; w._seq = -1; }         // free the worker
  if (d.token !== Renderer.token) { pumpRender(); return; } // stale: discard, keep going
  const W = Renderer.W;
  if (Renderer.esc && Renderer.esc.length >= W * Renderer.H) {
    Renderer.esc.set(d.buf, d.y0 * W);
    Renderer.inflight--;
    Renderer.doneBands++;
    Renderer.lastProgress = performance.now();
    afterBand();
  }
}

function afterBand() {
  if (Renderer.inflight > 0 || Renderer.queue.length > 0) { pumpRender(); return; }
  if (Renderer.doneBands >= Renderer.totalBands) presentPass();
}

function pumpRender() {
  if (!Renderer.active) return;
  const useWorkers = !FORCE_INLINE && workers.length > 0 && !workers.every(w => w.dead);
  if (useWorkers) {
    for (const w of workers) {
      if (Renderer.queue.length === 0) break;
      if (w.busy || w.dead) continue;
      const band = Renderer.queue.shift();
      const id = (Renderer.bandSeq = (Renderer.bandSeq || 0) + 1);
      w._seq = id;
      w.busy = true;
      Renderer.inflight++;
      w.postMessage({
        id, token: Renderer.token, y0: band[0], y1: band[1],
        block: CFG.PASSES[Renderer.passIdx].block,
        p: Renderer.params, power: state.power, usePow: state.power !== 2.0,
      });
    }
    if (Renderer.queue.length === 0 && Renderer.inflight === 0) {
      presentPass();
    }
    return;
  }
  // ---- inline, time-sliced ----
  const deadline = performance.now() + 13;
  const block = CFG.PASSES[Renderer.passIdx].block;
  while (Renderer.queue.length) {
    const band = Renderer.queue.shift();
    renderRows(Renderer.esc, band[0] * Renderer.W, Renderer.params, band[0], band[1], block);
    Renderer.doneBands++;
    if (performance.now() > deadline) break;
  }
  if (Renderer.queue.length === 0) presentPass();
}

function presentPass() {
  if (!Renderer.active) return;
  colorize(Renderer.esc, Renderer.W, Renderer.H, buf.u32,
           PAL_LUT[state.palette], PALETTES[state.palette].cyclic,
           state.smooth, state.cycle, state.spread, view.maxIter);
  gctx.putImageData(buf.img, 0, 0);
  Renderer.lastMs = performance.now() - Renderer.t0;
  Renderer.presented++;
  onPresented();
  const next = Renderer.passIdx + 1;
  if (next < CFG.PASSES.length) {
    beginPass(next);
  } else {
    Renderer.active = false;
    setProgress(1, true);
  }
}

/* Colour-only refresh: re-colorize the existing escape buffer, no iteration. */
function recolorize() {
  if (!Renderer.esc || !Renderer.W) return;
  colorize(Renderer.esc, Renderer.W, Renderer.H, buf.u32,
           PAL_LUT[state.palette], PALETTES[state.palette].cyclic,
           state.smooth, state.cycle, state.spread, view.maxIter);
  gctx.putImageData(buf.img, 0, 0);
}

/* ==================== F. PRESENTATION / OVERLAYS ======================= */
let fps = 0, fpsAcc = 0, fpsFrames = 0, fpsT = performance.now();
let passesDone = 0;

function setProgress(frac, hide) {
  const el = document.getElementById("progress");
  el.style.width = (frac * 100).toFixed(1) + "%";
  if (hide) setTimeout(() => { el.style.opacity = "0"; }, 220);
  else el.style.opacity = "1";
}
function onPresented() {
  passesDone++;
  setProgress(passesDone / CFG.PASSES.length, passesDone >= CFG.PASSES.length);
  updateStats();
}

/* --- resize: keep the complex view fixed, just re-rasterise ---------- */
function resize() {
  const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
  const cssW = Math.max(1, glCanvas.clientWidth || window.innerWidth);
  const cssH = Math.max(1, glCanvas.clientHeight || window.innerHeight);
  const W = Math.max(1, Math.round(cssW * dpr));
  const H = Math.max(1, Math.round(cssH * dpr));
  glCanvas.width = W; glCanvas.height = H;
  fxCanvas.width = W; fxCanvas.height = H;
  allocEsc(W, H);
  view.W = W; view.H = H; view.dpr = dpr;
  // rebase so the complex-space framing is preserved across aspect changes
  scheduleRender();
}

/* ------------------------- Julia inset pane --------------------------- */
const juliaCanvas = document.getElementById("juliaCanvas");
const jctx = juliaCanvas.getContext("2d", { alpha: false });
const JB = {
  W: CFG.JULIA_SIZE, H: CFG.JULIA_SIZE,
  img: jctx.createImageData(CFG.JULIA_SIZE, CFG.JULIA_SIZE),
  u32: null, esc: new Float32Array(CFG.JULIA_SIZE * CFG.JULIA_SIZE),
};
JB.u32 = new Uint32Array(JB.img.data.buffer);
let juliaStage = 0, juliaStageC = null;

function juliaSpan(re, im) {
  const r = Math.hypot(re, im);
  return 3.3 * Math.max(0.85, Math.min(2.6, 0.55 + 0.95 * r));
}
function juliaParams(re, im, W) {
  return makeParams(JB.W, JB.H, 0, 0, juliaSpan(re, im),
                    Math.round(CFG.JULIA_ITER * (W || 1)), "julia", re, im);
}
/* Two-stage inset draw: coarse this frame, full-res next frame. */
function requestJulia(re, im) {
  juliaStageC = { re, im };
  if (juliaStage === 0) {
    drawJuliaStage(3);
    juliaStage = 1;
    requestAnimationFrame(juliaNextStage);
  }
}
function juliaNextStage() {
  if (!juliaStageC) { juliaStage = 0; return; }
  if (juliaStage >= 1) {
    drawJuliaStage(1);
    juliaStage = 2;
    requestAnimationFrame(() => { juliaStage = 0; if (juliaStageC) juliaNextStage(); });
  } else {
    juliaStage = 0;
  }
}
function drawJuliaStage(block) {
  if (!juliaStageC) return;
  const { re, im } = juliaStageC;
  const p = juliaParams(re, im, block > 1 ? 0.6 : 1);
  renderRows(JB.esc, 0, p, 0, JB.H, block);
  colorize(JB.esc, JB.W, JB.H, JB.u32, PAL_LUT[state.palette],
           PALETTES[state.palette].cyclic, state.smooth, state.cycle, state.spread, p.maxIter);
  jctx.putImageData(JB.img, 0, 0);
  // mark the current c inside the inset
  jctx.save();
  jctx.strokeStyle = "rgba(255,255,255,.9)"; jctx.lineWidth = 1;
  jctx.beginPath(); jctx.moveTo(0, JB.H / 2); jctx.lineTo(JB.W, JB.H / 2);
  jctx.moveTo(JB.W / 2, 0); jctx.lineTo(JB.W / 2, JB.H);
  jctx.stroke();
  jctx.restore();
}

/* --------------------- interaction overlay (fx) ----------------------- */
const OV = { mode: 0, x0: 0, y0: 0, x1: 0, y1: 0, reticle: false, rx: 0, ry: 0 };

function drawOverlay() {
  fctx.clearRect(0, 0, fxCanvas.width, fxCanvas.height);
  const dpr = view.dpr;
  if (OV.mode === 1) {
    const x = Math.min(OV.x0, OV.x1), y = Math.min(OV.y0, OV.y1);
    const w = Math.abs(OV.x1 - OV.x0), h = Math.abs(OV.y1 - OV.y0);
    // OV coordinates are already in device pixels
    fctx.save();
    fctx.globalCompositeOperation = "difference";
    fctx.strokeStyle = "rgba(255,255,255,.95)";
    fctx.lineWidth = 1.5 * dpr;
    fctx.strokeRect(x, y, w, h);
    fctx.restore();
    fctx.save();
    fctx.fillStyle = "rgba(110,231,255,.10)";
    fctx.fillRect(x, y, w, h);
    fctx.strokeStyle = "rgba(110,231,255,.85)";
    fctx.lineWidth = 1 * dpr;
    fctx.setLineDash([4 * dpr, 4 * dpr]);
    fctx.strokeRect(x, y, w, h);
    fctx.restore();
    // magnification the selection would produce
    const zoom = DEFAULT_SCALE / (view.span * (h / fxCanvas.height));
    const label = fmtZoom(zoom) + `  ${(w / dpr).toFixed(0)}×${(h / dpr).toFixed(0)}`;
    fctx.save();
    fctx.font = `${11 * dpr}px ui-monospace, monospace`;
    const tw = fctx.measureText(label).width;
    const lx = x + w / 2 - tw / 2, ly = y + h + 16 * dpr;
    fctx.fillStyle = "rgba(6,8,14,.72)";
    fctx.fillRect(lx - 6 * dpr, ly - 12 * dpr, tw + 12 * dpr, 17 * dpr);
    fctx.fillStyle = "rgba(230,245,255,.98)";
    fctx.fillText(label, lx, ly);
    fctx.restore();
  } else if (OV.mode === 2) {  // pan indicator
    fctx.save();
    fctx.globalCompositeOperation = "difference";
    fctx.fillStyle = "rgba(255,255,255,.9)";
    fctx.beginPath(); fctx.arc(OV.x1, OV.y1, 5 * dpr, 0, 6.2832); fctx.fill();
    fctx.restore();
  }
  if (OV.reticle && state.mode === "mandelbrot") {
    fctx.save();
    fctx.globalCompositeOperation = "difference";
    fctx.strokeStyle = "rgba(255,255,255,.75)";
    fctx.lineWidth = 1 * dpr;
    const x = OV.rx, y = OV.ry, r = 9 * dpr;
    fctx.beginPath();
    fctx.moveTo(x - r, y); fctx.lineTo(x - 3 * dpr, y);
    fctx.moveTo(x + 3 * dpr, y); fctx.lineTo(x + r, y);
    fctx.moveTo(x, y - r); fctx.lineTo(x, y - 3 * dpr);
    fctx.moveTo(x, y + 3 * dpr); fctx.lineTo(x, y + r);
    fctx.stroke();
    fctx.restore();
  }
}

function fmtZoom(z) {
  if (z < 1000) return z.toFixed(z < 10 ? 2 : 1) + "×";
  const e = Math.floor(Math.log10(z));
  const m = z / Math.pow(10, e);
  return m.toFixed(2) + "e" + e;
}
function fmtCoord(v, span) {
  const d = Math.max(2, Math.min(16, Math.ceil(-Math.log10(span)) + 3));
  return v.toFixed(d);
}

/* ======================== G. INTERACTION =============================== */
const ptrs = new Map();
let drag = null;       // { kind:'zoom'|'pan'|'pinch', ... }
let momentum = { vx: 0, vy: 0, active: false };
let lastPointerCss = { x: 0, y: 0 };

function cssToCanvas(e) {
  const r = glCanvas.getBoundingClientRect();
  const d = view.dpr || 1;
  // return DEVICE pixels so every handler shares the renderer's coordinate space
  return { x: (e.clientX - r.left) * d, y: (e.clientY - r.top) * d };
}

function clampSpan(s) {
  const min = DEFAULT_SCALE / CFG.MAX_ZOOM;
  const max = DEFAULT_SCALE * 4;
  return Math.max(min, Math.min(max, s));
}

function showToast(msg, ms) {
  const t = document.getElementById("toast");
  t.textContent = msg;
  t.classList.add("show");
  clearTimeout(t._t);
  t._t = setTimeout(() => t.classList.remove("show"), ms || 2000);
}

let hudTimer = null, hudIdle = false;
function wakeHud() {
  const hud = document.getElementById("hud"), bot = document.getElementById("bottom");
  if (hudIdle) { hud.classList.remove("idle"); bot.classList.remove("idle"); hudIdle = false; }
  clearTimeout(hudTimer);
  hudTimer = setTimeout(() => {
    hud.classList.add("idle"); bot.classList.add("idle"); hudIdle = true;
  }, CFG.HUD_IDLE_MS);
}

/* ---- commit a view change: push history, reschedule ---- */
let depthWarned = 0;
function commitView(nextCenter, nextSpan, opts) {
  opts = opts || {};
  if (!opts.noHistory) {
    history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: view.mode,
                   jcRe: view.jcRe, jcIm: view.jcIm });
    if (history.length > 200) history.shift();
  }
  state.center.re = nextCenter.re; state.center.im = nextCenter.im;
  const want = nextSpan;
  state.scale = clampSpan(want);
  // Precision guard: double precision bottoms out around ×1e13 of magnification.
  const z = zoomOf(state.scale);
  if (state.scale > want * (1 + 1e-9)) {
    if (depthWarned !== 2) {
      depthWarned = 2;
      showToast("Max depth reached — double precision is exhausted at ×1e13", 3200);
    }
  } else if (z >= CFG.WARN_ZOOM && depthWarned === 0) {
    depthWarned = 1;
    showToast("Approaching the double-precision limit (×1e12) — fine detail will pixelate", 3200);
  } else if (z < CFG.WARN_ZOOM * 0.5) {
    depthWarned = 0;
  }
  updateStats();
  scheduleRender();
}

/* Apply a rectangle selection (canvas pixels) — the region framed is the
   region rendered: the rect is adjusted to the canvas aspect ratio around its
   own centre, so nothing silently shifts. */
function zoomToRect(x0, y0, x1, y1, noHistory) {
  const W = view.W, H = view.H;
  let rx = Math.min(x0, x1), ry = Math.min(y0, y1);
  let rw = Math.abs(x1 - x0), rh = Math.abs(y1 - y0);
  if (rw < 4 || rh < 4) return false;
  const targetAspect = W / H;
  // expand the smaller dimension symmetrically so the selection stays centred
  if (rw / rh > targetAspect) { const nh = rw / targetAspect; ry -= (nh - rh) / 2; rh = nh; }
  else { const nw = rh * targetAspect; rx -= (nw - rw) / 2; rw = nw; }
  const cxPix = rx + rw / 2, cyPix = ry + rh / 2;
  // map using the *same* affine model as the renderer
  const c = pixelToComplex(cxPix, cyPix, W, H, view.cx, view.cy, view.span);
  const newSpan = view.span * (rh / H);
  lastRect = { x: rx, y: ry, w: rw, h: rh };
  commitView({ re: c.re, im: c.im }, newSpan, { noHistory });
  return { cx: c.re, cy: c.im, span: newSpan, rect: lastRect };
}
let lastRect = null;

function zoomAt(px, py, factor) {
  const W = view.W, H = view.H;
  const p = pixelToComplex(px, py, W, H, view.cx, view.cy, view.span);
  const newSpan = clampSpan(view.span * factor);
  const k = newSpan / view.span;
  commitView({ re: p.re + (view.cx - p.re) * k, im: p.im + (view.cy - p.im) * k }, newSpan);
}

function panByPixels(dxPix, dyPix) {
  const W = view.W, H = view.H;
  const vw = view.span * W / H, vh = view.span;
  commitView({ re: view.cx - dxPix / W * vw, im: view.cy + dyPix / H * vh }, view.span,
             { noHistory: true });
}

/* ---------------------------- pointer events -------------------------- */
glCanvas.addEventListener("pointerdown", (e) => {
  wakeHud();
  glCanvas.setPointerCapture(e.pointerId);
  ptrs.set(e.pointerId, cssToCanvas(e));
  const c = cssToCanvas(e);
  const isPan = e.button === 2 || e.shiftKey || e.altKey || e.metaKey;
  if (ptrs.size === 2) {
    const [a, b] = [...ptrs.values()];
    drag = { kind: "pinch", d: Math.hypot(a.x - b.x, a.y - b.y), span: view.span, c: view.center };
    // anchor at the pinch midpoint
    const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
    drag.anchor = pixelToComplex(mx, my, view.W, view.H, view.cx, view.cy, view.span);
    OV.mode = 0;
  } else if (isPan) {
    drag = { kind: "pan", x: c.x, y: c.y, moved: 0 };
    OV.mode = 2; OV.x1 = c.x; OV.y1 = c.y;
  } else {
    drag = { kind: "zoom", x0: c.x, y0: c.y, x1: c.x, y1: c.y, moved: 0 };
  }
  momentum.active = false;
}, { passive: true });

glCanvas.addEventListener("pointermove", (e) => {
  wakeHud();
  const c = cssToCanvas(e);
  lastPointerCss = c;
  const prev = ptrs.get(e.pointerId);
  ptrs.set(e.pointerId, c);

  if (drag && drag.kind === "pinch" && ptrs.size >= 2) {
    const [a, b] = [...ptrs.values()];
    const d = Math.hypot(a.x - b.x, a.y - b.y);
    const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
    const f = Math.max(1e-4, drag.d / Math.max(1, d));
    const newSpan = clampSpan(drag.span * f);
    const k = newSpan / drag.span;
    const an = drag.anchor;
    // keep the pinch anchor pinned and follow the midpoint drift
    const cur = pixelToComplex(mx, my, view.W, view.H, view.cx, view.cy, view.span);
    commitView({ re: an.re + (cur.re - an.re) * k, im: an.im + (cur.im - an.im) * k },
               newSpan, { noHistory: true });
    return;
  }
  if (drag && drag.kind === "pan") {
    const dx = c.x - drag.x, dy = c.y - drag.y;
    drag.x = c.x; drag.y = c.y; drag.moved += Math.abs(dx) + Math.abs(dy);
    const before = { cx: view.cx, cy: view.cy };
    panByPixels(dx, dy);
    momentum.vx = view.cx - before.cx; momentum.vy = view.cy - before.cy;
    OV.x1 = c.x; OV.y1 = c.y;
    drawOverlay();
    return;
  }
  if (drag && drag.kind === "zoom") {
    drag.moved += Math.abs(c.x - drag.x1) + Math.abs(c.y - drag.y1);
    drag.x1 = c.x; drag.y1 = c.y;
    if (drag.moved > CFG.DRAG_THRESHOLD * (view.dpr || 1)) {
      OV.mode = 1;
      const rect = aspectRect(drag.x0, drag.y0, drag.x1, drag.y1);
      OV.x0 = rect.x; OV.y0 = rect.y; OV.x1 = rect.x + rect.w; OV.y1 = rect.y + rect.h;
      drawOverlay();
    }
    return;
  }
  if (prev) {
    // idle hover: reticle + live Julia preview
    OV.reticle = true; OV.rx = c.x; OV.ry = c.y;
    if (state.mode === "mandelbrot" && !idleHoverBlocked) {
      const p = pixelToComplex(c.x, c.y, view.W, view.H, view.cx, view.cy, view.span);
      requestJulia(p.re, p.im);
      setJuliaEditor(p.re, p.im, true);
    }
    drawOverlay();
  }
}, { passive: true });

glCanvas.addEventListener("pointerup", (e) => {
  const c = cssToCanvas(e);
  ptrs.delete(e.pointerId);
  if (drag && drag.kind === "zoom") {
    if (drag.moved > CFG.DRAG_THRESHOLD) {
      const rect = aspectRect(drag.x0, drag.y0, c.x, c.y);
      OV.mode = 0; drawOverlay();
      zoomToRect(rect.x, rect.y, rect.x + rect.w, rect.y + rect.h);
      showToast("Zoomed into selection");
    } else {
      // plain click: choose the Julia parameter
      const p = pixelToComplex(c.x, c.y, view.W, view.H, view.cx, view.cy, view.span);
      setJuliaParam(p.re, p.im, true);
      showToast("Julia preview set — press J to open it in the main view");
    }
  } else if (drag && drag.kind === "pan") {
    OV.mode = 0; drawOverlay();
    momentum.active = true;
  }
  drag = null;
}, { passive: true });

glCanvas.addEventListener("pointercancel", () => { ptrs.clear(); drag = null; OV.mode = 0; drawOverlay(); });
glCanvas.addEventListener("pointerleave", () => {
  OV.reticle = false; drawOverlay();
});
glCanvas.addEventListener("contextmenu", (e) => e.preventDefault());

/* rectangle selection constrains its own aspect ratio to the canvas so the
   drawn box is exactly the framed region */
function aspectRect(x0, y0, x1, y1) {
  const A = view.W / view.H;
  let dx = x1 - x0, dy = y1 - y0;
  let w = Math.abs(dx), h = Math.abs(dy);
  if (w / Math.max(h, 1e-6) > A) h = w / A; else w = h * A;
  return { x: dx < 0 ? x0 - w : x0, y: dy < 0 ? y0 - h : y0, w, h };
}

/* ------------------------------- wheel -------------------------------- */
glCanvas.addEventListener("wheel", (e) => {
  e.preventDefault();
  wakeHud();
  const c = cssToCanvas(e);
  if (ptrs.size >= 2) return;             // pinch owns the gesture
  let delta = e.deltaY;
  if (e.deltaMode === 1) delta *= 16;
  const factor = Math.exp(delta * CFG.WHEEL_RATE);
  zoomAt(c.x, c.y, factor);
  OV.reticle = true; OV.rx = c.x; OV.ry = c.y;
  if (state.mode === "mandelbrot") {
    const p = pixelToComplex(c.x, c.y, view.W, view.H, view.cx, view.cy, view.span);
    requestJulia(p.re, p.im);
    setJuliaEditor(p.re, p.im, true);
  }
}, { passive: false });

/* ------------------------------ keyboard ------------------------------- */
window.addEventListener("keydown", (e) => {
  if (e.target && (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")) return;
  wakeHud();
  const k = e.key;
  const pan = CFG.PAN_FRACTION * view.span;
  const vw = viewW();
  let handled = true;
  switch (k) {
    case "ArrowLeft":  commitView({ re: view.cx - vw * CFG.PAN_FRACTION, im: view.cy }, view.span); break;
    case "ArrowRight": commitView({ re: view.cx + vw * CFG.PAN_FRACTION, im: view.cy }, view.span); break;
    case "ArrowUp":    commitView({ re: view.cx, im: view.cy + pan }, view.span); break;
    case "ArrowDown":  commitView({ re: view.cx, im: view.cy - pan }, view.span); break;
    case "+": case "=": zoomAt(view.W / 2, view.H / 2, 1 / CFG.ZOOM_STEP); break;
    case "-": case "_": zoomAt(view.W / 2, view.H / 2, CFG.ZOOM_STEP); break;
    case "r": case "R": resetView(); break;
    case "Backspace": case "z": case "Z": goBack(); break;
    case "p": case "P": cyclePalette(1); break;
    case "[": state.cycle -= 0.04; applyRecolor(); break;
    case "]": state.cycle += 0.04; applyRecolor(); break;
    case "j": case "J": openJuliaInMain(); break;
    case "m": case "M": goMandelbrot(); break;
    case "s": case "S": saveBookmark(null); break;
    case "?": case "/": toggleHelp(); break;
    case "h": case "H": toggleHud(); break;
    default: handled = false;
  }
  if (handled) e.preventDefault();
});

/* ================ H. HUD / PALETTES / BOOKMARKS / HASH ================= */
const $ = (id) => document.getElementById(id);

function lutCss(name) {
  const lut = PAL_LUT[name];
  const pts = [];
  for (let i = 0; i <= 8; i++) {
    const v = lut[Math.round(i / 8 * (LUT_SIZE - 1))];
    const r = v & 255, g = (v >> 8) & 255, b = (v >> 16) & 255;
    pts.push(`rgb(${r},${g},${b}) ${(i / 8 * 100).toFixed(0)}%`);
  }
  return `linear-gradient(90deg, ${pts.join(",")})`;
}

function buildPaletteUI() {
  const wrap = $("palettes");
  wrap.innerHTML = "";
  PALETTE_NAMES.forEach((name, i) => {
    const d = document.createElement("div");
    d.className = "swatch" + (name === state.palette ? " sel" : "");
    d.style.background = lutCss(name);
    d.title = name + "  (key: number row / P cycles)";
    d.tabIndex = 0;
    d.setAttribute("role", "button");
    d.dataset.name = name;
    d.innerHTML = `<span>${(PALETTE_SHORT[name] || name).toUpperCase()}</span>`;
    const pick = () => { state.palette = name; syncPaletteUI(); applyRecolor(); };
    d.addEventListener("click", pick);
    d.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pick(); } });
    wrap.appendChild(d);
  });
}
function syncPaletteUI() {
  document.querySelectorAll(".swatch").forEach((d) => {
    d.classList.toggle("sel", d.dataset.name === state.palette);
  });
}
function cyclePalette(dir) {
  const i = PALETTE_NAMES.indexOf(state.palette);
  state.palette = PALETTE_NAMES[(i + dir + PALETTE_NAMES.length) % PALETTE_NAMES.length];
  syncPaletteUI(); applyRecolor();
}
function applyRecolor() {
  recolorize();
  if (juliaStageC) drawJuliaStage(1);
  updateStats();
}

/* ------------------------------ stats ---------------------------------- */
function updateStats() {
  const z = zoomOf(view.span);
  $("sZoom").textContent = fmtZoom(z);
  $("sCenter").textContent = `${fmtCoord(view.cx, view.span)}, ${fmtCoord(view.cy, view.span)}i`;
  $("sIter").textContent = view.maxIter + (state.power !== 2 ? ` · p=${state.power.toFixed(1)}` : "");
  $("sTime").textContent = Renderer.lastMs ? Renderer.lastMs.toFixed(0) + " ms" : "—";
  $("sMode").textContent = view.mode === "julia"
    ? `Julia c=${state.jc.re.toFixed(3)}${state.jc.im >= 0 ? "+" : ""}${state.jc.im.toFixed(3)}i`
    : "Mandelbrot";
  $("sFps").textContent = fps ? fps.toFixed(0) : "—";
  $("sFps").style.color = fps && fps < 24 ? "#ff9d7a" : "";
}

/* --------------------------- julia plumbing ---------------------------- */
let idleHoverBlocked = false;
function setJuliaParam(re, im, open) {
  state.jc.re = re; state.jc.im = im;
  requestJulia(re, im);
  setJuliaEditor(re, im, false);
  if (open) {
    $("juliaPanel").classList.remove("hidden");
    $("cVal").textContent = `c = ${re.toFixed(4)} ${im >= 0 ? "+" : "−"} ${Math.abs(im).toFixed(4)}i`;
  }
  updateStats();
}
function setJuliaEditor(re, im, live) {
  const el = $("cInput");
  const txt = `${re.toFixed(6)}, ${im.toFixed(6)}`;
  if (live) {
    if (document.activeElement === el) return;
    el.value = txt;
    $("cVal").textContent = `c = ${re.toFixed(4)} ${im >= 0 ? "+" : "−"} ${Math.abs(im).toFixed(4)}i`;
  } else if (document.activeElement !== el) {
    el.value = txt;
  }
}
$("cInput").addEventListener("input", () => {
  const m = $("cInput").value.split(/[,\s]+/).map(Number).filter((v) => !isNaN(v));
  if (m.length >= 2) { setJuliaParam(m[0], m[1], true); }
});
$("cInput").addEventListener("focus", () => { idleHoverBlocked = true; });
$("cInput").addEventListener("blur", () => { idleHoverBlocked = false; });

function openJuliaInMain() {
  if (state.mode === "mandelbrot") {
    state.mode = "julia";
    $("julia").classList.add("on");
    showToast(`Julia set for c = ${state.jc.re.toFixed(4)} ${state.jc.im >= 0 ? "+" : "−"} ${Math.abs(state.jc.im).toFixed(4)}i`);
  } else {
    showToast("Already in Julia mode");
    return;
  }
  state.center.re = 0; state.center.im = 0;
  state.scale = juliaSpan(state.jc.re, state.jc.im);
  history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: "mandelbrot", jcRe: view.jcRe, jcIm: view.jcIm });
  updateStats(); scheduleRender();
}
function goMandelbrot() {
  state.mode = "mandelbrot";
  $("julia").classList.remove("on");
  state.center.re = DEFAULT_CENTER.re; state.center.im = DEFAULT_CENTER.im;
  state.scale = DEFAULT_SCALE;
  history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: "julia", jcRe: view.jcRe, jcIm: view.jcIm });
  updateStats(); scheduleRender();
}
function resetView() {
  state.mode = "mandelbrot";
  $("julia").classList.remove("on");
  state.center.re = DEFAULT_CENTER.re; state.center.im = DEFAULT_CENTER.im;
  state.scale = DEFAULT_SCALE;
  history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: view.mode, jcRe: view.jcRe, jcIm: view.jcIm });
  updateStats(); scheduleRender();
  showToast("Reset to the full set");
}
function goBack() {
  if (!history.length) { showToast("Nothing to go back to"); return; }
  const h = history.pop();
  state.mode = h.mode === "julia" ? "julia" : "mandelbrot";
  $("julia").classList.toggle("on", state.mode === "julia");
  state.center.re = h.cx; state.center.im = h.cy; state.scale = h.span;
  if (state.mode === "julia") { state.jc.re = h.jcRe; state.jc.im = h.jcIm; }
  updateStats(); scheduleRender();
}

/* ---------------------------- bookmarks -------------------------------- */
const BM_KEY = "fractalExplorer.bookmarks.v1";
function loadBM() {
  try { return JSON.parse(localStorage.getItem(BM_KEY)) || []; } catch (e) { return []; }
}
function writeBM(list) {
  try { localStorage.setItem(BM_KEY, JSON.stringify(list)); } catch (e) {}
}
function snapshotState() {
  return {
    v: 1, mode: state.mode,
    cx: state.center.re, cy: state.center.im, span: state.scale,
    jcRe: state.jc.re, jcIm: state.jc.im,
    palette: state.palette, iterBoost: state.iterBoost, cycle: state.cycle,
    spread: state.spread, power: state.power, smooth: state.smooth,
  };
}
function restoreSnapshot(s, pushHist) {
  if (!s) return false;
  if (pushHist !== false) {
    history.push({ cx: view.cx, cy: view.cy, span: view.span, mode: view.mode,
                   jcRe: view.jcRe, jcIm: view.jcIm });
  }
  state.mode = s.mode === "julia" ? "julia" : "mandelbrot";
  state.center.re = +s.cx; state.center.im = +s.cy; state.scale = clampSpan(+s.span);
  if (s.jcRe !== undefined) { state.jc.re = +s.jcRe; state.jc.im = +s.jcIm; }
  if (s.palette && PAL_LUT[s.palette]) state.palette = s.palette;
  state.iterBoost = +s.iterBoost || 0;
  state.cycle = +s.cycle || 0;
  state.spread = s.spread === undefined ? 1 : +s.spread;
  state.power = +s.power || 2;
  state.smooth = s.smooth !== false;
  syncControls();
  $("julia").classList.toggle("on", state.mode === "julia");
  setJuliaParam(state.jc.re, state.jc.im, true);
  updateStats();
  scheduleRender();
  return true;
}
function syncControls() {
  $("iterBoost").value = state.iterBoost;
  $("cycle").value = Math.round(state.cycle * 100);
  $("spread").value = Math.round(state.spread * 100);
  $("power").value = Math.round(state.power * 50);
  syncPaletteUI();
}
function saveBookmark(name) {
  const list = loadBM();
  const nm = (name || $("bmName").value || "").trim() || ("View ×" + fmtZoom(zoomOf(view.span)));
  list.unshift({ name: nm, ts: Date.now(), data: snapshotState() });
  writeBM(list.slice(0, 60));
  $("bmName").value = "";
  renderBM();
  showToast(`Saved “${nm}”`);
}
function renderBM() {
  const list = loadBM();
  const wrap = $("bmList");
  wrap.innerHTML = "";
  if (!list.length) {
    wrap.innerHTML = `<div class="empty">No bookmarks yet — frame a view, name it, hit Save (or press S).</div>`;
    return;
  }
  list.forEach((bm, i) => {
    const d = document.createElement("div");
    d.className = "bm";
    const z = zoomOf(bm.data.span || DEFAULT_SCALE);
    d.innerHTML = `<div class="nm" title="${bm.name}"><div>${bm.name}</div>
      <div class="meta">${bm.data.mode === "julia" ? "Julia" : "Mandel"} · ${fmtZoom(z)} · ${bm.data.palette}</div></div>`;
    const go = document.createElement("button");
    go.textContent = "Load";
    go.onclick = () => { if (restoreSnapshot(bm.data)) showToast(`Restored “${bm.name}”`); };
    const del = document.createElement("button");
    del.textContent = "✕";
    del.title = "Delete";
    del.onclick = () => {
      const l = loadBM(); l.splice(i, 1); writeBM(l); renderBM();
    };
    d.appendChild(go); d.appendChild(del);
    wrap.appendChild(d);
  });
}

/* ------------------------------ URL hash ------------------------------- */
let hashTimer = null, lastHash = "";
function encodeHash() {
  const s = snapshotState();
  try { return "#v=" + btoa(JSON.stringify(s)).replace(/=+$/, ""); } catch (e) { return ""; }
}
function decodeHash() {
  const h = location.hash.replace(/^#/, "");
  if (!h) return null;
  try {
    if (h.startsWith("v=")) {
      const b = h.slice(2).replace(/-/g, "+").replace(/_/g, "/");
      return JSON.parse(atob(b));
    }
  } catch (e) {}
  return null;
}
function pushHash() {
  const h = encodeHash();
  if (!h || h === lastHash) return;      // avoid pointless history churn
  lastHash = h;
  try { history0.replaceState(null, "", h); } catch (e) { location.hash = h; }
}
const history0 = window.history;

/* ------------------------------ help / hud ----------------------------- */
function toggleHelp() { $("help").classList.toggle("show"); }
function toggleHud() {
  hudIdle = !hudIdle;
  $("hud").classList.toggle("idle", hudIdle);
  $("bottom").classList.toggle("idle", hudIdle);
}
function shareLink() {
  pushHash();
  const url = location.href;
  const done = () => showToast("Link copied — share the exact view");
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(url).then(done, () => showToast(url, 4000));
  } else showToast(url, 4000);
}

/* ===================== I. WIRING + MAIN LOOP =========================== */
$("back").onclick = goBack;
$("reset").onclick = resetView;
$("mandel").onclick = goMandelbrot;
$("julia").onclick = openJuliaInMain;
$("bmBtn").onclick = () => { $("bookmarks").classList.toggle("show"); renderBM(); };
$("bmSave").onclick = () => saveBookmark(null);
$("share").onclick = shareLink;
$("helpBtn").onclick = toggleHelp;
$("helpClose").onclick = () => $("help").classList.remove("show");
$("openJulia").onclick = openJuliaInMain;
$("bmName").addEventListener("keydown", (e) => { if (e.key === "Enter") saveBookmark(null); });

$("iterBoost").addEventListener("input", (e) => {
  state.iterBoost = +e.target.value; updateStats(); scheduleRender();
});
$("cycle").addEventListener("input", (e) => {
  state.cycle = +e.target.value / 100; applyRecolor();   // colour-only pass
});
$("spread").addEventListener("input", (e) => {
  state.spread = +e.target.value / 100; applyRecolor();
});
$("power").addEventListener("input", (e) => {
  state.power = +e.target.value / 50; updateStats(); scheduleRender();
});
window.addEventListener("resize", () => { clearTimeout(window._rt); window._rt = setTimeout(resize, 120); });
document.addEventListener("click", (e) => {
  const bm = $("bookmarks");
  if (bm.classList.contains("show") && !bm.contains(e.target) && e.target.id !== "bmBtn") {
    bm.classList.remove("show");
  }
});
window.addEventListener("hashchange", () => {
  if (location.hash === lastHash) return;       // our own write, nothing to do
  const s = decodeHash();
  if (s && s.cx !== undefined) { lastHash = location.hash; restoreSnapshot(s, false); }
});

/* main loop: FPS, momentum panning, overlay refresh */
let overlayDirty = false;
function frame(now) {
  requestAnimationFrame(frame);
  fpsFrames++;
  if (now - fpsT > 400) {
    fps = fpsFrames * 1000 / (now - fpsT);
    fpsT = now; fpsFrames = 0;
    $("sFps").textContent = fps.toFixed(0);
  }
  if (momentum.active) {
    const mag = Math.hypot(momentum.vx, momentum.vy);
    if (mag < view.span * CFG.MOMENTUM_MIN) momentum.active = false;
    else {
      commitView({ re: view.cx + momentum.vx * 9, im: view.cy + momentum.vy * 9 },
                 view.span, { noHistory: true });
      momentum.vx *= CFG.MOMENTUM; momentum.vy *= CFG.MOMENTUM;
    }
  }
  // watchdog: if the worker pool silently dies, fall back to the main thread
  if (Renderer.active && Renderer.inflight > 0 && !FORCE_INLINE &&
      now - (Renderer.lastProgress || 0) > 1500) {
    fallbackInline();
  }
  if (state.mode === "mandelbrot" && OV.reticle) {
    // live-update the reticle coordinate readout in the julia label
    overlayDirty = !overlayDirty;
  }
}
requestAnimationFrame(frame);

/* ---------------------------- boot ------------------------------------ */
function boot() {
  buildPaletteUI();
  renderBM();
  state.center.re = DEFAULT_CENTER.re; state.center.im = DEFAULT_CENTER.im;
  state.scale = DEFAULT_SCALE;
  syncControls();
  const hs = decodeHash();
  if (hs) restoreSnapshot(hs, false); else { updateStats(); }
  resize();
  setJuliaParam(state.jc.re, state.jc.im, true);
  wakeHud();
  // periodic hash sync (debounced) so links always reflect the live view
  setInterval(() => {
    if (document.activeElement && document.activeElement.tagName === "INPUT") return;
    clearTimeout(hashTimer);
    hashTimer = setTimeout(pushHash, 200);
  }, 900);
}
if (document.readyState === "complete" || document.readyState === "interactive") setTimeout(boot, 0);
else window.addEventListener("DOMContentLoaded", boot);

/* ============== J. HEADLESS / DEBUG INTERFACE (window.fractal) ========== */
/* Everything a test needs to reason about the math and the view, without
   touching the DOM or the render scheduler. */
window.fractal = {
  CFG, state, view, Renderer, PALETTES,

  /* --- math probes ------------------------------------------------- */
  /* Integer escape iteration count for a point in the CURRENT view's
     iteration budget. Interior points report exactly `maxIter` (the cap).
     Boundary note: the parabolic cusp c = 1/4 IS a member of the set — it
     converges to the neutral fixed point 1/2, so it correctly reports the cap.
     Points just outside it escape with a very large, finite count, e.g.
     c = 0.2501 needs 312 iterations (probe with a 4000-iteration budget). */
  iterationsAt(re, im, maxIter, mode, cRe, cIm) {
    const mi = maxIter || iterationsFor(state.scale);
    const julia = (mode || state.mode) === "julia";
    const jr = cRe === undefined ? state.jc.re : cRe;
    const ji = cIm === undefined ? state.jc.im : cIm;
    const mu = escapePoint(re, im, mi, julia, jr, ji, true, state.power !== 2);
    return mu < 0 ? mi : ES_LAST_N;
  },
  /* Full detail: {n, smooth, interior} */
  escape(re, im, maxIter) {
    const mi = maxIter || iterationsFor(state.scale);
    const mu = escapePoint(re, im, mi, state.mode === "julia", state.jc.re, state.jc.im,
                           true, state.power !== 2);
    if (mu < 0) return { n: mi, smooth: -1, interior: true };
    return { n: ES_LAST_N, smooth: mu, interior: false };
  },
  maxIterations() { return iterationsFor(state.scale); },

  /* --- view control ------------------------------------------------- */
  /* setView({re, im, scale, mode, c:[re,im], palette, iterBoost, smooth}) */
  setView(o) {
    if (!o) return this.getView();
    if (o.re !== undefined) state.center.re = +o.re;
    if (o.im !== undefined) state.center.im = +o.im;
    if (o.scale !== undefined) state.scale = clampSpan(+o.scale);
    if (o.mode) state.mode = o.mode;
    if (o.c) { state.jc.re = +o.c[0]; state.jc.im = +o.c[1]; }
    if (o.palette && PAL_LUT[o.palette]) state.palette = o.palette;
    if (o.iterBoost !== undefined) state.iterBoost = +o.iterBoost;
    if (o.smooth !== undefined) state.smooth = !!o.smooth;
    if (o.cycle !== undefined) state.cycle = +o.cycle;
    if (o.spread !== undefined) state.spread = +o.spread;
    if (o.power !== undefined) state.power = +o.power;
    syncControls();
    if (juliaStageC) setJuliaParam(state.jc.re, state.jc.im, true);
    updateStats();
    scheduleRender();
    return this.getView();
  },
  getView() {
    return {
      mode: state.mode, re: state.center.re, im: state.center.im,
      scale: state.scale, zoom: zoomOf(state.scale), maxIter: iterationsFor(state.scale),
      c: [state.jc.re, state.jc.im], palette: state.palette,
      W: view.W, H: view.H, dpr: view.dpr,
      onScreen: { cx: view.cx, cy: view.cy, span: view.span, maxIter: view.maxIter },
    };
  },
  /* Apply a rectangle selection in CSS pixels; returns the resulting view.
     The rect is snapped to the canvas aspect around its centre first, so
     drawn-region === rendered-region. */
  zoomToRect(x0, y0, x1, y1) {
    const d = view.dpr || 1;
    return zoomToRect(x0 * d, y0 * d, x1 * d, y1 * d);
  },
  lastRect() { return lastRect; },
  screenToComplex(xCss, yCss) {
    const d = view.dpr || 1;
    return pixelToComplex(xCss * d, yCss * d, view.W, view.H, view.cx, view.cy, view.span);
  },
  complexToScreen(re, im) {
    const p = complexToPixel(re, im, view.W, view.H, view.cx, view.cy, view.span);
    const d = view.dpr || 1;
    return { x: p.x / d, y: p.y / d };
  },
  back() { goBack(); },
  reset() { resetView(); },
  historyLength() { return history.length; },

  /* --- palettes / colour ------------------------------------------- */
  palettes() { return PALETTE_NAMES.slice(); },
  setPalette(name) { if (PAL_LUT[name]) { state.palette = name; syncPaletteUI(); applyRecolor(); } },
  setCycle(v) { state.cycle = +v; syncControls(); applyRecolor(); },

  /* --- rendering ---------------------------------------------------- */
  /* Render synchronously to completion at full resolution (used by tests).
     Cancels the progressive scheduler so the result is deterministic. */
  renderNow() {
    Renderer.active = false;
    Renderer.token++;
    const W = buf.W, H = buf.H;
    const p = makeParams(W, H, state.center.re, state.center.im, state.scale,
                         iterationsFor(state.scale), state.mode, state.jc.re, state.jc.im);
    Renderer.params = p;
    renderRows(buf.esc, 0, p, 0, H, 1);
    view.mode = state.mode; view.cx = state.center.re; view.cy = state.center.im;
    view.span = state.scale; view.jcRe = state.jc.re; view.jcIm = state.jc.im;
    view.maxIter = p.maxIter;
    colorize(buf.esc, W, H, buf.u32, PAL_LUT[state.palette],
             PALETTES[state.palette].cyclic, state.smooth, state.cycle, state.spread, p.maxIter);
    gctx.putImageData(buf.img, 0, 0);
    updateStats();
    return { W, H, ms: 0 };
  },
  /* Raw escape value of an already-rendered pixel (-1 = interior) */
  escapeBufferAt(x, y) {
    if (!buf) return null;
    if (x < 0 || y < 0 || x >= buf.W || y >= buf.H) return null;
    return buf.esc[y * buf.W + x];
  },
  /* Pixel colour of the last presented frame (0xRRGGBB) */
  pixelAt(x, y) {
    if (!buf) return null;
    if (x < 0 || y < 0 || x >= buf.W || y >= buf.H) return null;
    const v = buf.u32[y * buf.W + x];
    return { r: v & 255, g: (v >> 8) & 255, b: (v >> 16) & 255, a: (v >>> 24) & 255 };
  },
  isRendering() { return Renderer.active; },
  waitIdle() {
    return new Promise((res) => {
      const t = setInterval(() => {
        if (!Renderer.active && Renderer.presented > 0) { clearInterval(t); res(true); }
      }, 30);
      setTimeout(() => { clearInterval(t); res(false); }, 8000);
    });
  },
  maxZoom() { return CFG.MAX_ZOOM; },
  workerCount() { return FORCE_INLINE ? 0 : workers.filter(w => !w.dead).length; },
  FORCE_INLINE: () => FORCE_INLINE,

  /* --- bookmarks ---------------------------------------------------- */
  listBookmarks() { return loadBM(); },
  saveBookmark(name) { saveBookmark(name); return loadBM()[0]; },
  /* Restore a raw snapshot object (round-trip check for localStorage) */
  restoreSnapshot(s) { const ok = restoreSnapshot(s, false); return ok ? this.getView() : null; },
  snapshot() { return snapshotState(); },
  clearBookmarks() { writeBM([]); renderBM(); },
  hash() { return encodeHash(); },
  fromHash(h) {
    const s = (function (str) {
      try { const b = String(str).replace(/^#/, "").slice(2).replace(/-/g, "+").replace(/_/g, "/");
            return JSON.parse(atob(b)); } catch (e) { return null; } })(h);
    if (!s) return null;
    return restoreSnapshot(s, false) ? this.getView() : null;
  },

  /* internals for advanced probes */
  _renderRows: renderRows,
  _colorize: colorize,
  _pixelToComplex: pixelToComplex,
  _buf: () => buf,
  _workers: () => workers,
};
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"14ea754c-52b8-447f-bb1f-53541fef8b9d","tokensIn":10543294,"tokensOut":82254,"tokensTotal":10625548,"cost":0.10343888400000002,"turns":86,"toolCalls":85,"failedToolCalls":1,"timestamp":"2026-09-10T17:10:32.286Z"} -->