← Fractal Explorer results

Fractal Explorer

Claude haiku 5.5 · full

Initial view of Fractal Explorer

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Task text captured before this run. Effective runtime system instructions were not captured.

Generation prompt · 2978c368d8
# Task: Build an Interactive Fractal Explorer

Build an interactive Mandelbrot & Julia set explorer (vanilla JS, no external assets, no CDN, no libraries) with deep, smooth zooming and beautiful coloring. The top priority is **rendering performance and math precision** — frame-by-frame responsiveness while zooming, and correct iteration math at every depth.

## Core Specification

**Rendering Engine**
- Canvas 2D with an `ImageData`/typed-array pixel pipeline (compute escape iterations into a buffer, colorize, blit — no per-pixel DOM or rect calls)
- Escape-time algorithm with **smooth (continuous) coloring** — the fractional iteration count (`n + 1 - log2(log|z|)`) so bands don't appear
- Iteration limit that scales with zoom depth (deeper zooms need more iterations; tie it to magnification, with a manual override slider)
- Progressive rendering: draw a coarse pass fast (e.g. 1/4 resolution), refine to full resolution — the view must never freeze for seconds on interaction; cancel in-flight renders when the view changes
- Optional: render tiles via Web Workers (inline via Blob URL) — welcome if you can keep it simple, skip if it compromises correctness

**Navigation**
- **Click-and-drag rectangle zoom:** drag a selection box, release to zoom into exactly that region — with a live rectangle overlay
- **Wheel zoom** toward the cursor at a smooth rate; **drag to pan** (with momentum optional)
- Depth/magnification readout (e.g. `×3.4e9`), back button (zoom history stack), reset view
- **Julia mode:** click any point (or press J) to open the Julia set for that c-value — in a second pane or swapped main view; moving the cursor over the Mandelbrot view can live-preview the Julia set for the hovered c

**Coloring**
- At least 4 selectable palettes (e.g. fire, ocean, monochrome, psychedelic cyclic) with smooth interpolation between palette stops
- Interior (set membership) rendered black
- Palette cycling offset slider for animated color drift if desired (must not recompute iterations — colorize pass only)

**Precision & Limits**
- Standard double-precision zoom is fine to ~1e13 magnification; document and enforce a max-depth cap where precision visibly degrades (block zooming past it or warn)
- Save/load: bookmark named views (center, zoom, palette, iterations, set type, Julia c) into `localStorage`; URL-hash state (shareable link) optional but nice

## Visual Style
- Full-bleed canvas; a minimal glassy HUD (magnification, center coordinates, iterations, FPS) that hides when idle
- Crosshair/reticle in Julia-preview mode; elegant zoom-box overlay with inverted-color border
- Palette selector as swatch chips; everything keyboard-accessible (arrows pan, +/- zoom)

## Technical Requirements
- Vanilla JS; all code organized with clear section comments
- The core iteration loop must be allocation-free (no objects/arrays created per pixel per frame)
- Tunable constants block at the top (max iterations base/scale, zoom speed, palettes, progressive-refine passes)
- Expose a minimal debug interface on `window` (e.g. `fractal.iterationsAt(re, im)`, `fractal.setView({re, im, scale})`) so the math can be probed headlessly

## Quality Bar (must hit all)
- Iteration counts are mathematically correct: known interior points (e.g. c = 0, c = -1) reach max iterations; known exterior points (e.g. c = 2) escape immediately
- Drag-zoom lands on exactly the selected rectangle — the region framed is the region rendered (aspect-ratio handling must not silently shift it)
- Continuous coloring: no hard band edges when smooth coloring is on
- Interaction stays responsive while rendering: panning during a progressive render cancels and restarts without artifacts or ghost tiles
- A bookmark restored from `localStorage` reproduces the identical view

## Self-Check Before Finishing
Verify with the browser tools, using the debug interface:
- `iterationsAt(0, 0)` and `iterationsAt(-1, 0)` hit the max-iteration cap (interior); `iterationsAt(2, 0)` escapes in ≤ 3 iterations; `iterationsAt(0.25, 0)` (cusp boundary) shows the expected high-but-finite escape
- Zoom 1000× into a seahorse-valley location and confirm the render still shows structure (not flat noise or bands of precision garbage)
- Programmatically drag-select a rectangle and assert the resulting view center/scale match the selection math
- Save a bookmark, change the view, restore the bookmark, and assert the view state is identical
Inspect original source 38,111 bytes · SHA-256 bbbb4bc5aa3b
<!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>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#03040a;overflow:hidden;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#e8ecf5;user-select:none}
#main{position:fixed;left:0;top:0;width:100vw;height:100vh;display:block;z-index:1}
#ov{position:fixed;left:0;top:0;width:100vw;height:100vh;display:block;z-index:2;cursor:crosshair;touch-action:none}
#ov.pan{cursor:grabbing}
.glass{background:rgba(12,14,24,.55);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.12);border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.35)}
#hud{position:fixed;left:14px;top:14px;z-index:5;padding:10px 14px;font:12px/1.6 ui-monospace,Menlo,Consolas,monospace;transition:opacity .6s;min-width:250px;pointer-events:none}
#hud.idle{opacity:0}
#hud b{color:#9fd3ff;font-weight:600}
#hud .warn{color:#ffd58a}
#inset{position:fixed;right:14px;bottom:86px;z-index:5;width:244px;padding:6px;cursor:pointer}
#insetC{display:block;width:232px;height:232px;border-radius:8px}
#insetLabel{font:11px ui-monospace,Menlo,monospace;margin-top:5px;color:#cfd8ea;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#toolbar{position:fixed;left:50%;bottom:14px;transform:translateX(-50%);z-index:5;padding:8px 10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:center;max-width:calc(100vw - 28px);transition:opacity .6s}
#toolbar.idle{opacity:.2}
button,select{font:12px system-ui,sans-serif;color:#e8ecf5;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.16);border-radius:8px;padding:6px 10px;cursor:pointer}
button:hover,select:hover{background:rgba(255,255,255,.17)}
button:focus-visible,select:focus-visible,input:focus-visible{outline:2px solid #9fd3ff;outline-offset:2px}
button:disabled{opacity:.35;cursor:default}
button.on{background:rgba(159,211,255,.25);border-color:#9fd3ff}
.chip{width:34px;height:20px;padding:0;border-radius:6px;border:2px solid transparent}
.chip.sel{border-color:#fff;box-shadow:0 0 0 2px rgba(159,211,255,.7)}
label{font-size:11px;color:#cfd8ea;display:flex;align-items:center;gap:6px;white-space:nowrap}
input[type=range]{width:100px}
.sep{width:1px;height:22px;background:rgba(255,255,255,.15)}
#iterV{min-width:38px;font-family:ui-monospace,monospace;text-align:right}
#bmSel{max-width:170px}
#toast{position:fixed;top:14px;left:50%;transform:translateX(-50%);z-index:9;padding:8px 14px;font-size:13px;opacity:0;transition:opacity .3s;pointer-events:none;color:#ffd58a}
#toast.show{opacity:1}
#help{position:fixed;right:14px;top:14px;z-index:4;font:11px/1.5 ui-monospace,Menlo,monospace;color:#c3cde0;padding:6px 10px;pointer-events:none;max-width:44vw;text-align:right;text-shadow:0 1px 3px #000}
@media (max-width:900px){#inset{width:150px;bottom:150px}#insetC{width:138px;height:138px}#help{display:none}}
</style>
</head>
<body>
<canvas id="main"></canvas>
<canvas id="ov"></canvas>

<div id="hud" class="glass">
  <div><b id="hMode">Mandelbrot</b></div>
  <div>magnification <b id="hMag">×1.00</b></div>
  <div>center <span id="hCenter"></span></div>
  <div>iterations <span id="hIter"></span></div>
  <div>palette <span id="hPal"></span></div>
  <div>fps <span id="hFps">0</span> · <span id="hStatus">idle</span></div>
  <div id="hWarn" class="warn"></div>
</div>

<div id="inset" class="glass" title="Click to open this Julia set as the main view">
  <canvas id="insetC"></canvas>
  <div id="insetLabel"></div>
</div>

<div id="toolbar" class="glass">
  <button id="bBack" title="Back ([ or Backspace)">◀ Back</button>
  <button id="bReset" title="Reset view (R)">Reset</button>
  <button id="bJulia" title="Toggle Julia main view for the locked c (J)">Julia</button>
  <div class="sep"></div>
  <span id="chips" role="group" aria-label="Palette"></span>
  <div class="sep"></div>
  <label>Iter <input id="iterR" type="range" min="0" max="5000" step="10" value="0" aria-label="Iteration limit (0 = auto)"><span id="iterV">auto</span></label>
  <label>Cycle <input id="offR" type="range" min="0" max="1000" value="0" aria-label="Palette offset"></label>
  <button id="bAnim" title="Animate palette drift (A)">▶ Drift</button>
  <button id="bSmooth" class="on" title="Smooth coloring (S)">Smooth</button>
  <div class="sep"></div>
  <button id="bSave" title="Bookmark current view">★ Save view</button>
  <select id="bmSel" aria-label="Bookmarks"></select>
  <button id="bDel" title="Delete selected bookmark">✕</button>
</div>

<div id="toast"></div>
<div id="help">drag: box-zoom · shift/right/middle-drag: pan · wheel: zoom · click: pick c · ←↑→↓ pan · +/− zoom · [ back · R reset · J julia · P palette · S smooth · A drift</div>
<script>
(() => {
'use strict';

/* ================================================================
 * 1. TUNABLE CONSTANTS
 * ================================================================ */
const CONFIG = {
  BASE_H: 4,            // complex-plane height of the default (1x) view
  MAX_MAG: 1e13,        // double-precision depth cap: beyond ~1e13 the 53-bit mantissa
                        // cannot resolve neighbouring pixels near |c|~1, so zoom is blocked
  WARN_MAG: 1e12,       // HUD shows a precision warning above this magnification
  MAX_H: 16,            // zoom-out limit (complex height)
  ITER_BASE: 120,       // auto iterations = BASE + SCALE * log10(magnification)
  ITER_SCALE: 60,
  ITER_MAX: 20000,
  INSET_ITER: 300,      // iteration cap for the Julia inset pane
  JULIA_H: 4,           // complex height of the Julia view
  WHEEL_RATE: 0.0015,   // zoom exponent per wheel deltaY unit
  KEY_ZOOM: 1.5,        // +/- key zoom factor
  PASSES: [4, 2, 1],    // progressive refinement: 4 = coarse 1/16-sample pass, then 2, then full res
  SLICE_MS: 10,         // time budget per compute slice before yielding to the event loop
  BAILOUT: 256,         // escape radius (large radius -> accurate smooth colouring)
  PALETTE_FREQ: 0.06,   // palette cycles per iteration unit
  LUT_SIZE: 2048,       // palette lookup table entries (power of two)
  PIXEL_RATIO_MAX: 1.5, // render-buffer pixel ratio cap for speed
  HOVER_DELAY: 40       // ms throttle for Julia hover preview
};

/* ================================================================
 * 2. PALETTES  (stops are ping-ponged / cyclic, so colour never seams)
 * ================================================================ */
const TAU = Math.PI * 2;
const PALETTE_DEFS = [
  { name: 'Fire', stops: [[8,0,0],[110,12,0],[230,60,0],[255,170,30],[255,250,215],[255,170,30],[230,60,0],[110,12,0]] },
  { name: 'Ocean', stops: [[2,10,40],[5,50,110],[20,120,170],[120,220,230],[240,255,255],[120,220,230],[20,120,170],[5,50,110]] },
  { name: 'Mono', stops: [[0,0,0],[50,50,50],[150,150,150],[255,255,255],[150,150,150],[50,50,50]] },
  { name: 'Psychedelic', fn: t => [128 + 127 * Math.sin(TAU * 3 * t),
                                   128 + 127 * Math.sin(TAU * 3 * t + 2.094),
                                   128 + 127 * Math.sin(TAU * 3 * t + 4.188)] }
];

// Build a Uint32Array LUT of packed 0xAABBGGRR (little-endian ImageData layout).
function buildLUT(def) {
  const N = CONFIG.LUT_SIZE, lut = new Uint32Array(N), S = def.stops;
  for (let i = 0; i < N; i++) {
    const t = i / N;
    let r, g, b;
    if (def.fn) {
      const c = def.fn(t); r = c[0]; g = c[1]; b = c[2];
    } else {
      const p = t * S.length, i0 = Math.floor(p), f = p - i0;
      const a = S[i0 % S.length], c2 = S[(i0 + 1) % S.length];
      r = a[0] + (c2[0] - a[0]) * f;
      g = a[1] + (c2[1] - a[1]) * f;
      b = a[2] + (c2[2] - a[2]) * f;
    }
    lut[i] = (0xff000000 | (((b | 0) & 255) << 16) | (((g | 0) & 255) << 8) | ((r | 0) & 255)) >>> 0;
  }
  return lut;
}
const LUTS = PALETTE_DEFS.map(buildLUT);

function cssGradient(def) {
  const parts = [];
  for (let i = 0; i <= 8; i++) {
    let c;
    if (def.fn) c = def.fn(i / 8);
    else {
      const p = (i / 8) * def.stops.length, i0 = Math.floor(p), f = p - i0;
      const a = def.stops[i0 % def.stops.length], b = def.stops[(i0 + 1) % def.stops.length];
      c = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f];
    }
    parts.push(`rgb(${c[0] | 0},${c[1] | 0},${c[2] | 0}) ${(i / 8 * 100).toFixed(1)}%`);
  }
  return `linear-gradient(90deg,${parts.join(',')})`;
}

/* ================================================================
 * 3. ESCAPE-TIME ENGINE  (allocation-free inner loop)
 * ================================================================ */
const BAIL2 = CONFIG.BAILOUT * CONFIG.BAILOUT;
const INV_LN2 = 1 / Math.LN2;
let lastN = 0; // side-channel for integer iteration count (no allocation)

/*
 * Returns the smooth iteration value nu = n + 1 - log2(log|z_n|) (or integer n if !smooth),
 * or -1 for interior (set membership). Sets lastN to the integer escape count.
 *  mandel=true : z0 = (zr,zi) ignored -> z0 = 0, c = (cr,ci) is the pixel
 *  mandel=false: z0 = (zr,zi) is the pixel, c = (cr,ci) is the fixed Julia constant
 */
function escapeNu(zr, zi, cr, ci, maxIt, mandel, smooth) {
  if (mandel) {
    // Exact main-cardioid and period-2 bulb tests: interior points skip iteration.
    const xq = cr - 0.25, q = xq * xq + ci * ci;
    if (q * (q + xq) < 0.25 * ci * ci) { lastN = maxIt; return -1; }
    const xp = cr + 1;
    if (xp * xp + ci * ci < 0.0625) { lastN = maxIt; return -1; }
  }
  let zr2 = zr * zr, zi2 = zi * zi, n = 0;
  while (n < maxIt && zr2 + zi2 <= BAIL2) {
    zi = 2 * zr * zi + ci;
    zr = zr2 - zi2 + cr;
    zr2 = zr * zr;
    zi2 = zi * zi;
    n++;
  }
  lastN = n;
  if (n >= maxIt) return -1;
  if (!smooth) return n;
  const mod2 = zr2 + zi2;
  return n + 1 - Math.log(0.5 * Math.log(mod2)) * INV_LN2;
}

/* ================================================================
 * 4. RENDERER  (progressive, time-sliced, cancellable, dirty-rect blits)
 * ================================================================ */
class Renderer {
  constructor(canvas, passes) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.passes = passes;
    this.gen = 0;              // bumped on every render -> older jobs abort
    this.busy = false;
    this.onDone = null;
    this.lut = LUTS[0];
    this.offset = 0;
    this.smooth = true;
    this.state = { mode: 'mandel', cx: -0.5, cy: 0, h: CONFIG.BASE_H, c: { re: -0.8, im: 0.156 }, maxIter: CONFIG.ITER_BASE };
    this.bw = 0; this.bh = 0;
    this.nu = null; this.u32 = null; this.image = null;
  }

  resize(bw, bh) {
    this.bw = bw; this.bh = bh;
    this.canvas.width = bw; this.canvas.height = bh;
    this.image = this.ctx.createImageData(bw, bh);
    this.u32 = new Uint32Array(this.image.data.buffer);
    this.nu = new Float32Array(bw * bh);
  }

  // Colorize rows [y0,y1) from nu into the packed pixel buffer. Colour-only, no iteration.
  colorize(y0, y1) {
    const W = this.bw, nu = this.nu, u32 = this.u32, lut = this.lut, N = lut.length, MASK = N - 1;
    const off = this.offset, f = CONFIG.PALETTE_FREQ;
    for (let i = y0 * W, e = y1 * W; i < e; i++) {
      const v = nu[i];
      if (v < 0) { u32[i] = 0xff000000; continue; }   // interior -> black
      let t = v * f + off;
      t -= Math.floor(t);
      u32[i] = lut[((t * N) | 0) & MASK];
    }
  }

  blit(y0, y1) {
    this.ctx.putImageData(this.image, 0, 0, 0, y0, this.bw, y1 - y0);
  }

  recolor() {
    if (!this.nu) return;
    this.colorize(0, this.bh);
    this.blit(0, this.bh);
  }

  // Starts a new progressive render; any job still in flight is cancelled via gen.
  render() {
    const self = this, gen = ++this.gen, st = this.state, passes = this.passes;
    const W = this.bw, H = this.bh, nu = this.nu, smooth = this.smooth, maxIt = st.maxIter;
    const ps = st.h / H, halfW = W / 2, halfH = H / 2;
    const mandel = st.mode === 'mandel', cx = st.cx, cy = st.cy, jr = st.c.re, ji = st.c.im;
    let pi = 0, sy = 0;
    this.busy = true;

    const tick = () => {
      if (gen !== self.gen) return;                 // cancelled
      const t0 = performance.now();
      let y0 = -1, y1 = 0;
      while (pi < passes.length) {
        if (sy >= H) { pi++; sy = 0; break; }       // pass boundary: flush this slice first
        const s = passes[pi];
        const bottom = Math.min(H, sy + s);
        const ci = cy - (sy + 0.5 - halfH) * ps;
        for (let x = 0; x < W; x += s) {
          const cr = cx + (x + 0.5 - halfW) * ps;
          const v = mandel ? escapeNu(0, 0, cr, ci, maxIt, true, smooth)
                           : escapeNu(cr, ci, jr, ji, maxIt, false, smooth);
          const xe = Math.min(W, x + s);
          for (let yy = sy; yy < bottom; yy++) {
            const row = yy * W;
            for (let xx = x; xx < xe; xx++) nu[row + xx] = v;
          }
        }
        if (y0 < 0) y0 = sy;
        y1 = bottom;
        sy += s;
        if (performance.now() - t0 > CONFIG.SLICE_MS) break;
      }
      if (y0 >= 0) { self.colorize(y0, y1); self.blit(y0, y1); }
      if (pi >= passes.length) { self.busy = false; if (self.onDone) self.onDone(); return; }
      setTimeout(tick, 0);
    };
    tick();
  }
}

/* ================================================================
 * 5. APP STATE & GEOMETRY
 *    Complex transform uses buffer pixels: re = cx + (xb - W/2)*ps,
 *    im = cy - (yb - H/2)*ps, ps = h/H. Screen (CSS px) -> buffer via kx/ky.
 * ================================================================ */
const $ = id => document.getElementById(id);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const MIN_H = CONFIG.BASE_H / CONFIG.MAX_MAG;
const DEFAULT_VIEW = {
  mandel: { cx: -0.5, cy: 0, h: CONFIG.BASE_H },
  julia:  { cx: 0, cy: 0, h: CONFIG.JULIA_H }
};
const mainCanvas = $('main'), ovCanvas = $('ov'), octx = ovCanvas.getContext('2d');
const mainR = new Renderer(mainCanvas, CONFIG.PASSES);
const insetR = new Renderer($('insetC'), [4, 2, 1]);
const ui = {
  palette: 0, manualIter: 0,
  lockedC: { re: -0.8, im: 0.156 },
  hoverC: null, mouse: null,
  saved: { mandel: { ...DEFAULT_VIEW.mandel }, julia: { ...DEFAULT_VIEW.julia } },
  history: [], drift: false, box: null, drag: null,
  raf: 0, insetRaf: 0, lastAct: performance.now(), pr: 1
};
mainR.state = { mode: 'mandel', cx: -0.5, cy: 0, h: CONFIG.BASE_H, c: { ...ui.lockedC }, maxIter: CONFIG.ITER_BASE };
insetR.state = { mode: 'julia', cx: 0, cy: 0, h: 3.2, c: { ...ui.lockedC }, maxIter: CONFIG.INSET_ITER };

// Pixel-geometry snapshot for the main view (no allocation of arrays; one small object per call from UI only).
function geom() {
  const W = mainR.bw, H = mainR.bh;
  return { W, H, kx: W / innerWidth, ky: H / innerHeight, ps: mainR.state.h / H };
}
function screenToC(xc, yc) {
  const g = geom(), s = mainR.state;
  return { re: s.cx + (xc * g.kx - g.W / 2) * g.ps, im: s.cy - (yc * g.ky - g.H / 2) * g.ps };
}
function cToScreen(re, im) {
  const g = geom(), s = mainR.state;
  return { x: (g.W / 2 + (re - s.cx) / g.ps) / g.kx, y: (g.H / 2 - (im - s.cy) / g.ps) / g.ky };
}
function effIter(h) {
  if (ui.manualIter > 0) return ui.manualIter;
  const mag = CONFIG.BASE_H / h;
  return clamp(Math.round(CONFIG.ITER_BASE + CONFIG.ITER_SCALE * Math.log10(Math.max(1, mag))), CONFIG.ITER_BASE, CONFIG.ITER_MAX);
}
function clampH(nh) {
  // Returns the clamped complex height, or null if zooming is blocked at the precision cap.
  if (nh < MIN_H) {
    if (mainR.state.h <= MIN_H * 1.0001) { toast('Precision limit reached (≈1e13×). Zoom blocked.'); return null; }
    nh = MIN_H;
  }
  return nh > CONFIG.MAX_H ? CONFIG.MAX_H : nh;
}

/* ================================================================
 * 6. RENDER SCHEDULING, HISTORY, VIEW MUTATION
 * ================================================================ */
function startMainRender() {
  mainR.state.maxIter = effIter(mainR.state.h);
  mainR.render();
}
// Coalesce many pointer/wheel updates into one render per animation frame.
function scheduleMain() {
  if (!ui.raf) ui.raf = requestAnimationFrame(() => { ui.raf = 0; startMainRender(); });
  updateHUD();
}
function scheduleInset() {
  if (ui.insetRaf) return;
  ui.insetRaf = requestAnimationFrame(() => {
    ui.insetRaf = 0;
    insetR.state.c = { re: (ui.hoverC || ui.lockedC).re, im: (ui.hoverC || ui.lockedC).im };
    insetR.render();
    updateInsetLabel();
  });
}
function snapshot() {
  const s = mainR.state;
  return { mode: s.mode, cx: s.cx, cy: s.cy, h: s.h, c: { re: s.c.re, im: s.c.im } };
}
function pushHistory() {
  ui.history.push(snapshot());
  if (ui.history.length > 300) ui.history.shift();
}
function restoreSnapshot(v) {
  const s = mainR.state;
  s.mode = v.mode; s.cx = v.cx; s.cy = v.cy; s.h = v.h; s.c = { re: v.c.re, im: v.c.im };
  ui.lockedC = { re: s.c.re, im: s.c.im };
  scheduleMain(); scheduleInset(); updateModeButtons();
}
function back() {
  if (!ui.history.length) { toast('No earlier views'); return; }
  restoreSnapshot(ui.history.pop());
}
function setMode(m) {
  const s = mainR.state;
  if (m === s.mode) return;
  ui.saved[s.mode] = { cx: s.cx, cy: s.cy, h: s.h };
  s.mode = m;
  const v = ui.saved[m];
  s.cx = v.cx; s.cy = v.cy; s.h = v.h;
  s.c = { re: ui.lockedC.re, im: ui.lockedC.im };
  scheduleMain(); updateModeButtons();
}
function toggleJulia() {
  pushHistory();
  setMode(mainR.state.mode === 'mandel' ? 'julia' : 'mandel');
}
function resetView() {
  pushHistory();
  const s = mainR.state, d = DEFAULT_VIEW[s.mode];
  s.cx = d.cx; s.cy = d.cy; s.h = d.h;
  scheduleMain();
}
// Zoom by `factor` (>1 zooms in) keeping the complex point under (xc,yc) fixed on screen.
function zoomAt(xc, yc, factor) {
  const s = mainR.state, p = screenToC(xc, yc);
  const nh = clampH(s.h / factor);
  if (nh === null) return false;
  const g = geom();
  const ps = nh / g.H, xb = xc * g.kx, yb = yc * g.ky;
  s.cx = p.re - (xb - g.W / 2) * ps;
  s.cy = p.im + (yb - g.H / 2) * ps;
  s.h = nh;
  scheduleMain();
  return true;
}
function panBy(dxCss, dyCss) {
  const s = mainR.state, g = geom();
  s.cx -= dxCss * g.kx * g.ps;
  s.cy += dyCss * g.ky * g.ps;
  scheduleMain();
}
// Box is aspect-locked to the window so the framed region == the drawn rectangle exactly.
function aspectBox(sx, sy, x, y) {
  const aspect = innerWidth / innerHeight;
  const dx = x - sx, dy = y - sy;
  const w = Math.max(Math.abs(dx), Math.abs(dy) * aspect), h = w / aspect;
  return {
    x0: sx, y0: sy,
    x1: sx + (dx < 0 ? -w : w),
    y1: sy + (dy < 0 ? -h : h)
  };
}
function zoomToBox(b) {
  const s = mainR.state;
  const mid = screenToC((b.x0 + b.x1) / 2, (b.y0 + b.y1) / 2);
  const hCss = Math.abs(b.y1 - b.y0);
  const nh = clampH(s.h * hCss / innerHeight);
  if (nh === null) return false;
  s.cx = mid.re; s.cy = mid.im; s.h = nh;
  scheduleMain();
  return true;
}

/* ================================================================
 * 7. PALETTE, OPTIONS, JULIA PANE
 * ================================================================ */
function setPalette(i) {
  ui.palette = i;
  mainR.lut = insetR.lut = LUTS[i];
  mainR.recolor(); insetR.recolor();
  document.querySelectorAll('#chips button').forEach((b, k) => b.classList.toggle('sel', k === i));
  $('hPal').textContent = PALETTE_DEFS[i].name;
}
function buildChips() {
  const wrap = $('chips');
  PALETTE_DEFS.forEach((p, i) => {
    const b = document.createElement('button');
    b.className = 'chip';
    b.title = p.name;
    b.setAttribute('aria-label', 'Palette ' + p.name);
    b.style.background = cssGradient(p);
    b.onclick = () => setPalette(i);
    wrap.appendChild(b);
  });
}
function updateInsetLabel() {
  const c = insetR.state.c;
  $('insetLabel').textContent = `Julia c = ${c.re.toFixed(5)} ${c.im < 0 ? '−' : '+'} ${Math.abs(c.im).toFixed(5)}i ${ui.hoverC ? '(preview)' : '(locked)'}`;
}
function updateModeButtons() {
  const j = mainR.state.mode === 'julia';
  $('bJulia').classList.toggle('on', j);
  $('bJulia').textContent = j ? '◀ Mandelbrot' : 'Julia';
  $('hMode').textContent = j ? 'Julia' : 'Mandelbrot';
}
function setSmooth(on) {
  mainR.smooth = insetR.smooth = on;
  $('bSmooth').classList.toggle('on', on);
  scheduleMain(); scheduleInset();
}
function setOffset(v) {
  mainR.offset = insetR.offset = v;
  $('offR').value = Math.round(((v % 1) + 1) % 1 * 1000);
  mainR.recolor(); insetR.recolor();
}
function toast(msg) {
  const t = $('toast');
  t.textContent = msg; t.classList.add('show');
  clearTimeout(toast._t);
  toast._t = setTimeout(() => t.classList.remove('show'), 1800);
}
function setPickedC(c) {
  ui.lockedC = { re: c.re, im: c.im };
  mainR.state.c = { re: c.re, im: c.im };
  scheduleInset();
}

/* ================================================================
 * 8. BOOKMARKS & URL HASH (state round-trips exactly through JSON / String)
 * ================================================================ */
const BM_KEY = 'fractal-explorer.bookmarks.v1';
function loadBookmarks() {
  try { const a = JSON.parse(localStorage.getItem(BM_KEY) || '[]'); return Array.isArray(a) ? a : []; }
  catch (e) { return []; }
}
function storeBookmarks(list) {
  try { localStorage.setItem(BM_KEY, JSON.stringify(list)); } catch (e) { toast('localStorage unavailable'); }
  refreshBookmarkSelect();
}
function currentState() {
  const s = mainR.state;
  return {
    mode: s.mode, cx: s.cx, cy: s.cy, h: s.h,
    c: { re: ui.lockedC.re, im: ui.lockedC.im },
    palette: ui.palette, iter: ui.manualIter, smooth: mainR.smooth, offset: mainR.offset
  };
}
function applyState(v) {
  const s = mainR.state;
  ui.saved[v.mode] = { cx: v.cx, cy: v.cy, h: v.h };
  s.mode = v.mode; s.cx = v.cx; s.cy = v.cy; s.h = v.h;
  ui.lockedC = { re: v.c.re, im: v.c.im };
  s.c = { re: v.c.re, im: v.c.im };
  ui.manualIter = v.iter || 0;
  $('iterR').value = ui.manualIter;
  $('iterV').textContent = ui.manualIter ? String(ui.manualIter) : 'auto';
  mainR.smooth = insetR.smooth = v.smooth !== false;
  $('bSmooth').classList.toggle('on', mainR.smooth);
  setPalette(v.palette || 0);
  setOffset(v.offset || 0);
  updateModeButtons();
  scheduleMain(); scheduleInset();
}
function saveBookmark(name) {
  const list = loadBookmarks();
  name = name || `View ${list.length + 1} · ×${fmtMag(CONFIG.BASE_H / mainR.state.h)}`;
  const entry = { name, ts: Date.now(), ...currentState() };
  const i = list.findIndex(b => b.name === name);
  if (i >= 0) list[i] = entry; else list.push(entry);
  storeBookmarks(list);
  toast('Saved: ' + name);
  return name;
}
function loadBookmark(name) {
  const b = loadBookmarks().find(x => x.name === name);
  if (!b) return false;
  applyState(b);
  return true;
}
function deleteBookmark(name) {
  storeBookmarks(loadBookmarks().filter(x => x.name !== name));
}
function refreshBookmarkSelect() {
  const sel = $('bmSel'), cur = sel.value;
  sel.innerHTML = '';
  const o0 = document.createElement('option');
  o0.value = ''; o0.textContent = `Bookmarks (${loadBookmarks().length})`;
  sel.appendChild(o0);
  loadBookmarks().forEach(b => {
    const o = document.createElement('option');
    o.value = b.name; o.textContent = b.name;
    sel.appendChild(o);
  });
  sel.value = cur && [...sel.options].some(o => o.value === cur) ? cur : '';
}
function writeHash() {
  const s = currentState();
  const q = new URLSearchParams({
    m: s.mode, x: String(s.cx), y: String(s.cy), h: String(s.h),
    cr: String(s.c.re), ci: String(s.c.im), p: String(s.palette),
    n: String(s.iter), s: s.smooth ? '1' : '0'
  });
  try { history.replaceState(null, '', '#' + q.toString()); } catch (e) { /* file:// sandboxes may refuse */ }
}
function readHash() {
  const raw = location.hash.slice(1);
  if (!raw || raw.indexOf('h=') < 0) return null;
  const p = new URLSearchParams(raw);
  const h = Number(p.get('h')), cx = Number(p.get('x')), cy = Number(p.get('y'));
  if (!isFinite(h) || !isFinite(cx) || !isFinite(cy) || h <= 0) return null;
  return {
    mode: p.get('m') === 'julia' ? 'julia' : 'mandel', cx, cy, h,
    c: { re: Number(p.get('cr')) || 0, im: Number(p.get('ci')) || 0 },
    palette: Number(p.get('p')) || 0, iter: Number(p.get('n')) || 0, smooth: p.get('s') !== '0', offset: 0
  };
}

/* ================================================================
 * 9. HUD & FORMATTING
 * ================================================================ */
function fmtMag(m) {
  if (m >= 1e4) return m.toExponential(2).replace('e+', 'e');
  return m.toFixed(2);
}
function fmtCoord(v) { return (v < 0 ? '−' : '+') + Math.abs(v).toPrecision(14); }
function updateHUD() {
  const s = mainR.state, mag = CONFIG.BASE_H / s.h;
  $('hMag').textContent = '×' + fmtMag(mag);
  $('hCenter').textContent = `${fmtCoord(s.cx)} ${fmtCoord(s.cy)}i`;
  $('hIter').textContent = `${effIter(s.h)}${ui.manualIter ? ' (manual)' : ' (auto)'}`;
  $('iterV').textContent = ui.manualIter ? String(ui.manualIter) : 'auto';
  $('hWarn').textContent = mag > CONFIG.WARN_MAG ? '⚠ near double-precision limit (≈1e13×)' : '';
  $('hMode').textContent = s.mode === 'julia' ? `Julia  c = ${fmtCoord(ui.lockedC.re)} ${fmtCoord(ui.lockedC.im)}i` : 'Mandelbrot';
  $('hStatus').textContent = mainR.busy ? 'rendering' : 'idle';
}
function drawOverlay() {
  const pr = ui.pr, w = innerWidth, h = innerHeight;
  octx.setTransform(ovCanvas.width / w, 0, 0, ovCanvas.height / h, 0, 0);
  octx.clearRect(0, 0, w, h);
  const s = mainR.state;
  if (s.mode === 'mandel') {
    // Reticle marks the locked Julia constant c.
    const p = cToScreen(ui.lockedC.re, ui.lockedC.im);
    octx.strokeStyle = 'rgba(255,255,255,.9)';
    octx.lineWidth = 1.2;
    octx.beginPath();
    octx.arc(p.x, p.y, 7, 0, Math.PI * 2);
    octx.moveTo(p.x - 13, p.y); octx.lineTo(p.x - 4, p.y);
    octx.moveTo(p.x + 4, p.y); octx.lineTo(p.x + 13, p.y);
    octx.moveTo(p.x, p.y - 13); octx.lineTo(p.x, p.y - 4);
    octx.moveTo(p.x, p.y + 4); octx.lineTo(p.x, p.y + 13);
    octx.stroke();
    // Live crosshair at the cursor (Julia-preview reticle).
    if (ui.mouse && !ui.drag) {
      const m = ui.mouse;
      octx.strokeStyle = 'rgba(159,211,255,.55)';
      octx.beginPath();
      octx.moveTo(m.x, 0); octx.lineTo(m.x, h);
      octx.moveTo(0, m.y); octx.lineTo(w, m.y);
      octx.stroke();
    }
  }
  if (ui.box) {
    const b = ui.box;
    const x = Math.min(b.x0, b.x1), y = Math.min(b.y0, b.y1);
    const bw = Math.abs(b.x1 - b.x0), bh = Math.abs(b.y1 - b.y0);
    octx.fillStyle = 'rgba(255,255,255,.07)';
    octx.fillRect(x, y, bw, bh);
    octx.save();
    octx.globalCompositeOperation = 'difference';   // inverted-colour border
    octx.strokeStyle = '#ffffff';
    octx.lineWidth = 1.5;
    octx.strokeRect(x + 0.5, y + 0.5, bw - 1, bh - 1);
    octx.restore();
  }
}

/* ================================================================
 * 10. CANVAS SIZING
 *     Buffer height = innerHeight*pr, width = H*aspect, so the buffer aspect matches the
 *     window exactly and one scale (h/innerHeight) maps CSS px to the complex plane.
 * ================================================================ */
function resizeAll() {
  ui.pr = Math.min(window.devicePixelRatio || 1, CONFIG.PIXEL_RATIO_MAX);
  const H = Math.max(2, Math.round(innerHeight * ui.pr));
  const W = Math.max(2, Math.round(H * innerWidth / innerHeight));
  mainR.resize(W, H);
  const ovDpr = window.devicePixelRatio || 1;
  ovCanvas.width = Math.round(innerWidth * ovDpr);
  ovCanvas.height = Math.round(innerHeight * ovDpr);
  const IS = 232 * ui.pr | 0;
  insetR.resize(IS, IS);
  mainR.lut = insetR.lut = LUTS[ui.palette];
  scheduleMain(); scheduleInset();
  drawOverlay();
}

/* ================================================================
 * 11. INPUT: POINTER (box zoom / pan / click-to-pick c / hover preview)
 * ================================================================ */
const DRAG_PX = 4;
function gestureBegin() { ui.lastAct = performance.now(); }
function commitHistoryThen(fn) {
  pushHistory();
  if (!fn()) ui.history.pop();   // blocked (precision cap) -> no history entry
}
ovCanvas.addEventListener('pointerdown', e => {
  gestureBegin();
  ovCanvas.setPointerCapture(e.pointerId);
  const isPan = e.button === 1 || e.button === 2 || e.shiftKey;
  ui.drag = { pan: isPan, sx: e.clientX, sy: e.clientY, x: e.clientX, y: e.clientY, moved: false, pushed: false };
  if (isPan) ovCanvas.classList.add('pan');
  e.preventDefault();
});
ovCanvas.addEventListener('pointermove', e => {
  gestureBegin();
  ui.mouse = { x: e.clientX, y: e.clientY };
  const d = ui.drag;
  if (d) {
    const dist = Math.hypot(e.clientX - d.sx, e.clientY - d.sy);
    if (d.pan) {
      if (!d.pushed) { pushHistory(); d.pushed = true; }
      panBy(e.clientX - d.x, e.clientY - d.y);
      d.x = e.clientX; d.y = e.clientY;
    } else if (dist > DRAG_PX) {
      d.moved = true;
      ui.box = aspectBox(d.sx, d.sy, e.clientX, e.clientY);
    }
  } else if (mainR.state.mode === 'mandel') {
    // Live Julia preview for the hovered c (throttled by rAF via scheduleInset).
    const c = screenToC(e.clientX, e.clientY);
    ui.hoverC = c;
    scheduleInset();
  }
  drawOverlay();
});
ovCanvas.addEventListener('pointerup', e => {
  const d = ui.drag;
  ui.drag = null;
  ovCanvas.classList.remove('pan');
  if (d && !d.pan && d.moved && ui.box) {
    const b = ui.box;
    ui.box = null;
    commitHistoryThen(() => zoomToBox(b));
  } else if (d && !d.pan && !d.moved) {
    // Click: pick the Julia constant in Mandelbrot mode.
    if (mainR.state.mode === 'mandel') {
      const c = screenToC(e.clientX, e.clientY);
      setPickedC(c);
      ui.hoverC = null;
      updateHUD();
      toast(`c = ${fmtCoord(c.re)} ${fmtCoord(c.im)}i  (J for Julia)`);
    }
  }
  ui.box = null;
  drawOverlay();
});
ovCanvas.addEventListener('pointercancel', () => { ui.drag = null; ui.box = null; drawOverlay(); });
ovCanvas.addEventListener('pointerleave', () => {
  if (!ui.drag) { ui.mouse = null; ui.hoverC = null; scheduleInset(); drawOverlay(); }
});
ovCanvas.addEventListener('contextmenu', e => e.preventDefault());
let lastWheel = 0;
ovCanvas.addEventListener('wheel', e => {
  e.preventDefault();
  gestureBegin();
  const now = performance.now();
  if (now - lastWheel > 500) pushHistory();      // one history entry per wheel gesture
  lastWheel = now;
  let dy = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
  dy = clamp(dy, -240, 240);
  zoomAt(e.clientX, e.clientY, Math.exp(-dy * CONFIG.WHEEL_RATE));
  drawOverlay();
}, { passive: false });
$('inset').addEventListener('click', () => {
  if (mainR.state.mode === 'julia') return;
  pushHistory();
  setPickedC(insetR.state.c);
  setMode('julia');
});

/* ================================================================
 * 12. INPUT: KEYBOARD & TOOLBAR
 * ================================================================ */
function zoomCenter(f) {
  commitHistoryThen(() => zoomAt(innerWidth / 2, innerHeight / 2, f));
}
window.addEventListener('keydown', e => {
  gestureBegin();
  const tag = (e.target && e.target.tagName) || '';
  if (tag === 'INPUT' || tag === 'SELECT') return;
  const k = e.key;
  const pan = Math.round(innerWidth * 0.12);
  if (k === 'ArrowLeft')  { pushHistoryOnce(); panBy(pan, 0); }
  else if (k === 'ArrowRight') { pushHistoryOnce(); panBy(-pan, 0); }
  else if (k === 'ArrowUp')    { pushHistoryOnce(); panBy(0, pan); }
  else if (k === 'ArrowDown')  { pushHistoryOnce(); panBy(0, -pan); }
  else if (k === '+' || k === '=') zoomCenter(CONFIG.KEY_ZOOM);
  else if (k === '-' || k === '_') zoomCenter(1 / CONFIG.KEY_ZOOM);
  else if (k === '[' || k === 'Backspace') { e.preventDefault(); back(); }
  else if (k === 'r' || k === 'R') resetView();
  else if (k === 'j' || k === 'J') {
    if (mainR.state.mode === 'mandel' && ui.hoverC) setPickedC(ui.hoverC);
    toggleJulia();
  }
  else if (k === 'p' || k === 'P') setPalette((ui.palette + 1) % PALETTE_DEFS.length);
  else if (k === 's' || k === 'S') setSmooth(!mainR.smooth);
  else if (k === 'a' || k === 'A') toggleDrift();
  else return;
  e.preventDefault();
  drawOverlay();
});
// Arrow-key panning: history once per key burst (coalesced within 400 ms).
let lastKeyPan = 0;
function pushHistoryOnce() {
  const now = performance.now();
  if (now - lastKeyPan > 400) pushHistory();
  lastKeyPan = now;
}
function toggleDrift() {
  ui.drift = !ui.drift;
  $('bAnim').textContent = ui.drift ? '■ Stop' : '▶ Drift';
  $('bAnim').classList.toggle('on', ui.drift);
  if (ui.drift) driftStep();
}
function driftStep() {
  if (!ui.drift) return;
  setOffset(mainR.offset + 0.0025);   // colour-only; iterations untouched
  requestAnimationFrame(driftStep);
}
$('bBack').onclick = back;
$('bReset').onclick = resetView;
$('bJulia').onclick = () => {
  if (mainR.state.mode === 'mandel') setPickedC(ui.hoverC || ui.lockedC);
  toggleJulia();
};
$('bAnim').onclick = toggleDrift;
$('bSmooth').onclick = () => setSmooth(!mainR.smooth);
$('bSave').onclick = () => saveBookmark();
$('bDel').onclick = () => {
  const n = $('bmSel').value;
  if (!n) { toast('Select a bookmark first'); return; }
  deleteBookmark(n); toast('Deleted: ' + n);
};
$('bmSel').onchange = e => { if (e.target.value) { pushHistory(); loadBookmark(e.target.value); } };
$('iterR').oninput = e => {
  ui.manualIter = Number(e.target.value);
  $('iterV').textContent = ui.manualIter ? String(ui.manualIter) : 'auto';
  scheduleMain();
};
$('offR').oninput = e => setOffset(Number(e.target.value) / 1000);
['pointerdown', 'pointermove', 'keydown'].forEach(t => window.addEventListener(t, gestureBegin, { passive: true }));
window.addEventListener('resize', resizeAll);

// HUD idle fade + FPS (blit count per second)
setInterval(() => {
  const idle = performance.now() - ui.lastAct > 2500 && !ui.drag;
  $('hud').classList.toggle('idle', idle);
  $('toolbar').classList.toggle('idle', idle);
  const now = performance.now();
  const dt = (now - (setInterval._t || now)) / 1000;
  setInterval._t = now;
  const fps = dt > 0 ? mainR.blits / dt : 0;
  mainR.blits = 0;
  $('hFps').textContent = String(Math.round(fps));
  updateHUD();
  $('insetLabel').parentNode.style.display = mainR.state.mode === 'julia' ? 'none' : 'block';
}, 250);

/* ================================================================
 * 13. DEBUG / PROBE INTERFACE  (window.fractal)
 * ================================================================ */
function mandelCount(re, im, maxIt) {
  escapeNu(0, 0, re, im, maxIt, true, false);
  return lastN;
}
window.fractal = {
  // Integer escape count for Mandelbrot c = re + i·im (maxIt reached => interior).
  iterationsAt(re, im, maxIt) { return mandelCount(re, im, maxIt || 1000); },
  // Same, but with no cardioid/bulb shortcut: pure iteration (used to cross-check the shortcut).
  iterationsAtRaw(re, im, maxIt) {
    let zr = 0, zi = 0, n = 0; const m = maxIt || 1000;
    while (n < m && zr * zr + zi * zi <= BAIL2) { const t = zr * zr - zi * zi + re; zi = 2 * zr * zi + im; zr = t; n++; }
    return n;
  },
  // Smooth value for Mandelbrot c (-1 for interior).
  smoothAt(re, im, maxIt) { return escapeNu(0, 0, re, im, maxIt || 1000, true, true); },
  getView() {
    const s = mainR.state;
    return { mode: s.mode, cx: s.cx, cy: s.cy, h: s.h, scale: CONFIG.BASE_H / s.h,
             c: { re: ui.lockedC.re, im: ui.lockedC.im }, palette: ui.palette, iter: ui.manualIter, maxIter: effIter(s.h), smooth: mainR.smooth };
  },
  // scale = magnification (1 = default view). Sets view exactly, no history entry.
  setView(v) {
    const s = mainR.state;
    if (v.mode) s.mode = v.mode;
    const cx = v.re !== undefined ? v.re : (v.cx !== undefined ? v.cx : s.cx);
    const cy = v.im !== undefined ? v.im : (v.cy !== undefined ? v.cy : s.cy);
    s.cx = cx; s.cy = cy;
    if (v.scale !== undefined) s.h = clamp(CONFIG.BASE_H / v.scale, MIN_H, CONFIG.MAX_H);
    scheduleMain(); updateModeButtons();
    return this.getView();
  },
  // Drag-select in CSS pixels; returns the aspect-locked box and resulting view.
  dragSelect(x0, y0, x1, y1) {
    const box = aspectBox(x0, y0, x1, y1);
    const before = this.getView();
    commitHistoryThen(() => zoomToBox(box));
    return { box, before, after: this.getView() };
  },
  // Map CSS px -> complex and back (for selection math checks).
  screenToComplex(x, y) { return screenToC(x, y); },
  complexToScreen(re, im) { return cToScreen(re, im); },
  saveBookmark(name) { return saveBookmark(name); },
  loadBookmark(name) { return loadBookmark(name); },
  deleteBookmark(name) { deleteBookmark(name); },
  listBookmarks() { return loadBookmarks(); },
  back, resetView,
  render() { startMainRender(); },
  // Resolves once the progressive render has fully completed.
  whenIdle() {
    return new Promise(res => {
      const poll = () => (mainR.busy ? setTimeout(poll, 20) : res(this.getView()));
      setTimeout(poll, 0);
    });
  },
  maxMagnification: CONFIG.MAX_MAG,
  config: CONFIG
};

/* ================================================================
 * 14. INIT
 * ================================================================ */
mainR.blits = 0;
const origBlit = Renderer.prototype.blit;
Renderer.prototype.blit = function (y0, y1) { if (this === mainR) this.blits++; origBlit.call(this, y0, y1); };
mainR.onDone = () => { updateHUD(); };
insetR.onDone = () => {};
buildChips();
refreshBookmarkSelect();
const fromHash = readHash();
if (fromHash) applyState(fromHash);
else { setPalette(0); updateModeButtons(); }
mainR.smooth = insetR.smooth = true;
resizeAll();
updateHUD();
writeHash();
setInterval(writeHash, 1000);

})();
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"cc435c77-f624-473f-b670-aa2f13adee03","tokensIn":281876,"tokensOut":102276,"tokensTotal":384152,"cost":0.05847240499999999,"turns":10,"toolCalls":13,"failedToolCalls":0,"timestamp":"2026-10-08T01:53:18.606Z"} -->