← Fractal Explorer results

Fractal Explorer

Qwen 3.6 27b MTP · composer

Initial view of Fractal Explorer

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 53,823 bytes · SHA-256 19d68d88a85e
<!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 Sets</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; color: #fff; }

#viewport { position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: crosshair; }
#fractal-canvas { display: block; width: 100%; height: 100%; image-rendering: auto; }

/* Zoom overlay rectangle */
#zoom-overlay { position: absolute; border: 2px solid rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); pointer-events: none; display: none; }

/* HUD */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s ease; }
#hud.hidden-hud { opacity: 0; }

#hud-top { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; pointer-events: auto; }
#info-panel { display: flex; gap: 16px; background: rgba(0,0,0,0.5); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 6px 14px; font-size: 13px; font-family: 'SF Mono', 'Fira Code', monospace; }
#info-panel span { white-space: nowrap; }

#controls-top { display: flex; gap: 6px; pointer-events: auto; }
#controls-top button, #hud-bottom button { background: rgba(255,255,255,0.1); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.15); color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: background 0.2s; }
#controls-top button:hover, #hud-bottom button:hover { background: rgba(255,255,255,0.2); }
#controls-top button.active { background: rgba(100,180,255,0.3); border-color: rgba(100,180,255,0.5); }

/* Bottom HUD */
#hud-bottom { position: absolute; bottom: 0; left: 0; width: 100%; display: flex; align-items: center; justify-content: center; gap: 16px; padding: 12px 16px; pointer-events: auto; background: linear-gradient(transparent, rgba(0,0,0,0.5)); }

/* Palette bar */
#palette-bar { display: flex; gap: 8px; align-items: center; }
.palette-swatch { width: 28px; height: 28px; border-radius: 6px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s; position: relative; overflow: hidden; }
.palette-swatch:hover { transform: scale(1.15); }
.palette-swatch.active { border-color: #fff; box-shadow: 0 0 8px rgba(255,255,255,0.4); }

/* Sliders */
input[type="range"] { -webkit-appearance: none; appearance: none; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; outline: none; cursor: pointer; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #fff; border-radius: 50%; cursor: pointer; }
#color-offset { width: 120px; }
#iter-slider { width: 140px; }

/* Bookmark panel */
#bookmark-panel { position: absolute; top: 60px; right: 16px; background: rgba(0,0,0,0.7); backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 16px; min-width: 240px; pointer-events: auto; z-index: 10; }
#bookmark-panel h3 { font-size: 14px; margin-bottom: 8px; opacity: 0.7; text-transform: uppercase; letter-spacing: 1px; }
#bookmark-list { max-height: 200px; overflow-y: auto; margin-bottom: 8px; }
.bookmark-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: background 0.2s; }
.bookmark-item:hover { background: rgba(255,255,255,0.1); }
.bookmark-item button { background: none; border: none; color: #f66; cursor: pointer; font-size: 14px; padding: 2px 6px; }

#bookmark-name { width: 100%; padding: 6px 8px; margin-bottom: 8px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; color: #fff; font-size: 13px; outline: none; }
#bookmark-name::placeholder { color: rgba(255,255,255,0.4); }

/* Julia preview label */
#julia-preview-label { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); background: rgba(100,180,255,0.3); backdrop-filter: blur(8px); border: 1px solid rgba(100,180,255,0.4); border-radius: 6px; padding: 4px 12px; font-size: 12px; font-family: monospace; pointer-events: none; }

/* Utility */
.hidden { display: none !important; }

/* Scrollbar styling */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
/* ── css: crosshair_and_hud_enhancements ── */
/* Crosshair overlay */
#crosshair { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.ch-line { position: absolute; background: rgba(255,255,255,0.3); }
.ch-h { width: 100%; height: 1px; top: 50%; left: 0; }
.ch-v { width: 1px; height: 100%; top: 0; left: 50%; }
.ch-dot { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,0.6); top: calc(50% - 4px); left: calc(50% - 4px); }

/* HUD improvements */
#info-panel { font-size: 12px; letter-spacing: 0.3px; }
#mag-display { color: #7df; }
#center-display { color: #cfc; }
#iter-display { color: #fcc; }
#fps-display { color: #ffc; }

/* Zoom overlay with inverted border effect */
#zoom-overlay { 
  position: absolute; 
  border: 2px solid rgba(255,255,255,0.9); 
  background: rgba(255,255,255,0.03); 
  pointer-events: none; 
  display: none;
  box-shadow: 0 0 0 1px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.3);
}

/* Slider labels */
#hud-bottom { gap: 24px; }
.slider-group { display: flex; align-items: center; gap: 6px; font-size: 11px; color: rgba(255,255,255,0.6); }

/* Keyboard hints */
#key-hints { position: absolute; bottom: 50px; right: 16px; font-size: 10px; color: rgba(255,255,255,0.3); text-align: right; line-height: 1.6; pointer-events: none; }
</style>
</head>
<body>
<div id="viewport">
  <canvas id="fractal-canvas"></canvas>
  <div id="zoom-overlay" class="hidden"></div>
<div id="crosshair" class="hidden">
  <div class="ch-line ch-h"></div>
  <div class="ch-line ch-v"></div>
  <div class="ch-dot"></div>
</div></div>
<div id="hud">
  <div id="hud-top">
    <span id="mag-display">×1.0</span>
<span id="center-display">(-0.5, 0)</span>
<span id="iter-display">Iter: 200</span>
<span id="fps-display">60 FPS</span>
<span id="mode-display" style="color:#f9a;font-weight:bold;">Mandelbrot</span>
    <div id="controls-top">
      <button id="btn-reset" title="Reset View (R)">⟲</button>
      <button id="btn-back" title="Zoom Back (Z)" class="hidden">←</button>
      <button id="btn-julia" title="Julia Mode (J)">J</button>
    </div>
  </div>
  <div id="palette-bar"></div>
<div class="slider-group">
  <label for="color-offset">Color</label>
  <input type="range" id="color-offset" min="0" max="1" step="0.001" value="0" title="Color Offset (C)">
</div>
<div class="slider-group">
  <label for="iter-slider">Iter</label>
  <input type="range" id="iter-slider" min="50" max="2000" step="10" value="200" title="Max Iterations (I)">
</div>
<div id="key-hints">
  ←→↑↓ pan · +/- zoom<br>
  drag pan · long-drag zoom box<br>
  click Mandelbrot → Julia<br>
  J toggle · Z back · R reset · B bookmarks
</div>
</div>
<div id="bookmark-panel" class="hidden">
  <h3>Bookmarks</h3>
  <div id="bookmark-list"></div>
  <input type="text" id="bookmark-name" placeholder="Bookmark name...">
  <button id="btn-save-bookmark">Save View</button>
  <button id="btn-close-bookmarks">Close (B)</button>
</div>
<div id="julia-preview-label" class="hidden">Julia Set Preview — c = <span id="julia-c-display"></span></div>
<script>
'use strict';

// ── const: CONFIG ──
// ============================================================
// TUNABLE CONSTANTS
// ============================================================
const CONFIG = {
  // Iteration limits
  baseIterations: 200,
  iterationsPerZoomLog: 80,   // extra iterations per log10(magnification)
  maxIterations: 5000,        // hard cap

  // Zoom behavior
  zoomSpeed: 0.3,             // wheel zoom factor (e^zoomSpeed - 1)
  dragZoomMinPixels: 8,       // minimum drag distance to trigger rectangle zoom
  panDragThreshold: 4,        // pixels before we consider it a pan vs click

  // Progressive rendering
  progressivePasses: [0.25, 0.5, 1.0],  // resolution fractions per pass
  tileSize: 64,               // worker tile size in pixels

  // Precision limits
  maxMagnification: 1e13,     // beyond this, double precision degrades visibly

  // HUD auto-hide delay (ms)
  hudHideDelay: 3000,

  // Julia preview update throttle (ms)
  juliaPreviewThrottle: 50,

  // FPS tracking window
  fpsWindowFrames: 60,
};

// ── const: PALETTES ──
// ============================================================
// PALETTE DEFINITIONS
// Each palette is an array of {pos, r, g, b} stops for interpolation.
// pos ranges from 0 to 1.
// ============================================================
const PALETTES = {
  fire: {
    name: 'Fire',
    gradient: [
      { pos: 0.0, r: 0,   g: 0,   b: 0   },
      { pos: 0.15, r: 80,  g: 0,   b: 0   },
      { pos: 0.3,  r: 200, g: 40,  b: 0   },
      { pos: 0.5,  r: 255, g: 160, b: 0   },
      { pos: 0.7,  r: 255, g: 240, b: 80  },
      { pos: 0.9,  r: 255, g: 255, b: 220 },
      { pos: 1.0,  r: 255, g: 255, b: 255 },
    ],
    swatchColors: ['#000', '#500', '#c28', '#fa0', '#ff6', '#fff'],
  },
  ocean: {
    name: 'Ocean',
    gradient: [
      { pos: 0.0,  r: 0,   g: 10,  b: 40  },
      { pos: 0.2,  r: 0,   g: 30,  b: 100 },
      { pos: 0.4,  r: 0,   g: 80,  b: 160 },
      { pos: 0.6,  r: 20,  g: 150, b: 200 },
      { pos: 0.8,  r: 100, g: 220, b: 240 },
      { pos: 1.0,  r: 200, g: 255, b: 255 },
    ],
    swatchColors: ['#00a', '#06c', '#08a', '#29c', '#6dc', '#cff'],
  },
  monochrome: {
    name: 'Mono',
    gradient: [
      { pos: 0.0, r: 0,   g: 0,   b: 0   },
      { pos: 1.0, r: 255, g: 255, b: 255 },
    ],
    swatchColors: ['#000', '#444', '#888', '#ccc', '#fff'],
  },
  psychedelic: {
    name: 'Psychedelic',
    gradient: [
      { pos: 0.0, r: 128, g: 0,   b: 128 },
      { pos: 0.16,r: 0,   g: 0,   b: 255 },
      { pos: 0.33,r: 0,   g: 255, b: 255 },
      { pos: 0.5, r: 0,   g: 255, b: 0   },
      { pos: 0.66,r: 255, g: 255, b: 0   },
      { pos: 0.83,r: 255, g: 0,   b: 0   },
      { pos: 1.0, r: 128, g: 0,   b: 128 },
    ],
    swatchColors: ['#808', '#00f', '#0ff', '#0f0', '#ff0', '#f00'],
  },
  electric: {
    name: 'Electric',
    gradient: [
      { pos: 0.0, r: 5,   g: 5,   b: 30  },
      { pos: 0.2, r: 20,  g: 0,   b: 120 },
      { pos: 0.4, r: 80,  g: 0,   b: 200 },
      { pos: 0.6, r: 150, g: 50,  b: 255 },
      { pos: 0.8, r: 220, g: 150, b: 255 },
      { pos: 1.0, r: 255, g: 240, b: 255 },
    ],
    swatchColors: ['#003', '#148', '#50c', '#96f', '#d9f', '#faf'],
  },
};

// ── const: PALETTE_KEYS ──
const PALETTE_KEYS = Object.keys(PALETTES);

// ── function: mandelbrotIteration ──
// ============================================================
// MATH UTILITIES & COLOR FUNCTIONS
// ============================================================

/**
 * Compute Mandelbrot iteration count for a point c = (cre, cim).
 * Returns the smooth (continuous) iteration count.
 * If the point is in the set (doesn't escape), returns maxIter + 1.
 */
function mandelbrotIteration(cre, cim, maxIter) {
  let zr = 0, zi = 0;
  let zr2 = 0, zi2 = 0;
  for (let n = 0; n < maxIter; n++) {
    // z = z^2 + c:  (zr+izi)^2 = zr^2 - zi^2 + i*2*zr*zi
    zi = 2 * zr * zi + cim;
    zr = zr2 - zi2 + cre;
    zr2 = zr * zr;
    zi2 = zi * zi;
    if (zr2 + zi2 > 256) {
      // Smooth iteration count: n + 1 - log2(log|z|)
      const log_zn = Math.log(zr2 + zi2) * 0.5;
      return n + 1 - Math.log(log_zn) / Math.LN2;
    }
  }
  return maxIter + 1; // interior point
}

// ── function: juliaIteration ──
/**
 * Compute Julia set iteration count for a point z = (zre, zim) with parameter c.
 */
function juliaIteration(zre, zim, cre, cim, maxIter) {
  let zr = zre, zi = zim;
  let zr2 = zr * zr, zi2 = zi * zi;
  for (let n = 0; n < maxIter; n++) {
    zi = 2 * zr * zi + cim;
    zr = zr2 - zi2 + cre;
    zr2 = zr * zr;
    zi2 = zi * zi;
    if (zr2 + zi2 > 256) {
      const log_zn = Math.log(zr2 + zi2) * 0.5;
      return n + 1 - Math.log(log_zn) / Math.LN2;
    }
  }
  return maxIter + 1;
}

// ── function: samplePalette ──
/**
 * Interpolate a palette value at position t (0..1), with optional cyclic offset.
 * Returns {r, g, b}.
 */
function samplePalette(paletteKey, t, colorOffset) {
  const pal = PALETTES[paletteKey];
  if (!pal) return { r: 0, g: 0, b: 0 };

  // Apply cyclic offset (wraps around for psychedelic, clamps for others)
  let pos;
  if (paletteKey === 'psychedelic') {
    pos = ((t + colorOffset) % 1.0 + 1.0) % 1.0;
  } else {
    pos = Math.max(0, Math.min(1, t + colorOffset));
  }

  const stops = pal.gradient;
  // Find surrounding stops
  let lo = stops[0], hi = stops[stops.length - 1];
  for (let i = 0; i < stops.length - 1; i++) {
    if (pos >= stops[i].pos && pos <= stops[i + 1].pos) {
      lo = stops[i];
      hi = stops[i + 1];
      break;
    }
  }

  // Interpolate between stops
  const range = hi.pos - lo.pos;
  const frac = range > 0 ? (pos - lo.pos) / range : 0;
  return {
    r: Math.round(lo.r + (hi.r - lo.r) * frac),
    g: Math.round(lo.g + (hi.g - lo.g) * frac),
    b: Math.round(lo.b + (hi.b - lo.b) * frac),
  };
}

// ── function: computeIterations ──
/**
 * Compute effective iteration count based on zoom depth.
 */
function computeIterations(magnification, manualOverride) {
  if (manualOverride !== null && manualOverride !== undefined) return manualOverride;
  const logMag = Math.log10(Math.max(1, magnification));
  return Math.min(CONFIG.maxIterations, Math.round(CONFIG.baseIterations + CONFIG.iterationsPerZoomLog * logMag));
}

// ── function: pixelToComplex ──
/**
 * Convert canvas pixel coordinates to complex plane coordinates.
 */
function pixelToComplex(px, py, canvasW, canvasH, centerRe, centerIm, magnification) {
  const aspect = canvasW / canvasH;
  // Viewport: width in complex plane is proportional to 1/magnification
  const viewHeight = 3.0 / magnification;
  const viewWidth = viewHeight * aspect;
  return {
    re: centerRe + (px / canvasW - 0.5) * viewWidth,
    im: centerIm + (py / canvasH - 0.5) * viewHeight,
  };
}

// ── function: complexToPixel ──
/**
 * Convert complex plane coordinates to canvas pixel coordinates.
 */
function complexToPixel(re, im, canvasW, canvasH, centerRe, centerIm, magnification) {
  const aspect = canvasW / canvasH;
  const viewHeight = 3.0 / magnification;
  const viewWidth = viewHeight * aspect;
  return {
    x: (re - centerRe) / viewWidth * canvasW + canvasW * 0.5,
    y: (im - centerIm) / viewHeight * canvasH + canvasH * 0.5,
  };
}

// ── function: createWorker ──
// ============================================================
// RENDERER — ImageData pipeline with Web Worker tiles & progressive rendering
// ============================================================

/**
 * Create a Web Worker from inline code using a Blob URL.
 */
function createWorker(code) {
  const blob = new Blob([code], { type: 'application/javascript' });
  return new Worker(URL.createObjectURL(blob));
}

// ── function: paletteToWorkerFormat ──
/**
 * Convert palette gradient to compact array format for worker.
 */
function paletteToWorkerFormat(paletteKey) {
  const pal = PALETTES[paletteKey];
  return pal.gradient.map(s => [s.pos, s.r, s.g, s.b]);
}

// ── class: FractalRenderer ──
// ============================================================
// RENDERER — ImageData pipeline with Web Worker tiles & progressive rendering
// ============================================================

class FractalRenderer {
  constructor(canvas) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d');
    this.renderId = 0; // incremented on each new render to invalidate old tiles
    this.isRendering = false;
    this.numWorkers = Math.min(navigator.hardwareConcurrency || 4, 8);

    // Create workers once at startup
    this.workers = [];
    for (let i = 0; i < this.numWorkers; i++) {
      const w = createWorker(WORKER_SCRIPT);
      // Send palette data to worker as init messages
      for (const key of PALETTE_KEYS) {
        w.postMessage({ __init: true, paletteKey: key, data: paletteToWorkerFormat(key) });
      }
      this.workers.push(w);
    }

    // Setup message handlers — workers check renderId to ignore stale tiles
    this.workers.forEach(w => {
      w.onmessage = (e) => {
        const d = e.data;
        if (d.__init) return; // init ack, ignore
        if (d.renderId !== this.renderId) return; // stale tile from cancelled render
        this._handleTileResult(d);
      };
    });

    this._tempCanvas = document.createElement('canvas');
  }

  /**
   * Start rendering with given view parameters.
   * Cancels any in-flight render first (by incrementing renderId).
   */
  startRender(centerRe, centerIm, magnification, maxIter, paletteKey, colorOffset, setMode, juliaCre, juliaCim) {
    // Cancel previous render by bumping the ID — old tiles will be ignored
    this.renderId++;

    this.isRendering = true;
    const currentRenderId = this.renderId;
    this.viewState = { centerRe, centerIm, magnification, maxIter, paletteKey, colorOffset, setMode: setMode || 'mandelbrot', juliaCre: juliaCre || 0, juliaCim: juliaCim || 0 };

    const canvasW = this.canvas.width;
    const canvasH = this.canvas.height;

    // Progressive rendering: start with coarse pass
    this._currentPassIndex = 0;
    this._renderPass(canvasW, canvasH);
  }

  _renderPass(canvasW, canvasH) {
    if (this.renderId !== this.viewState.__renderId && !('centerRe' in this.viewState)) return;

    const resolution = CONFIG.progressivePasses[this._currentPassIndex];
    const renderW = Math.max(1, Math.round(canvasW * resolution));
    const renderH = Math.max(1, Math.round(canvasH * resolution));

    // Create image data at render resolution
    this._renderImageData = new ImageData(renderW, renderH);
    this._pendingTiles = 0;
    this._completedTiles = 0;
    this._currentResolution = resolution;

    const tileSize = CONFIG.tileSize;
    const tilesX = Math.ceil(renderW / tileSize);
    const tilesY = Math.ceil(renderH / tileSize);

    // Distribute tiles to workers in a round-robin fashion
    let workerIdx = 0;
    for (let ty = 0; ty < tilesY; ty++) {
      for (let tx = 0; tx < tilesX; tx++) {
        const tileW = Math.min(tileSize, renderW - tx * tileSize);
        const tileH = Math.min(tileSize, renderH - ty * tileSize);

        this.workers[workerIdx].postMessage({
          renderId: this.renderId,
          tileX: tx * tileSize,
          tileY: ty * tileSize,
          tileW, tileH,
          canvasW: renderW,
          canvasH: renderH,
          centerRe: this.viewState.centerRe,
          centerIm: this.viewState.centerIm,
          magnification: this.viewState.magnification,
          maxIter: this.viewState.maxIter,
          paletteKey: this.viewState.paletteKey,
          colorOffset: this.viewState.colorOffset,
          setMode: this.viewState.setMode,
          juliaCre: this.viewState.juliaCre,
          juliaCim: this.viewState.juliaCim,
        });

        this._pendingTiles++;
        workerIdx = (workerIdx + 1) % this.numWorkers;
      }
    }

    // If no tiles needed, blit immediately
    if (tilesX === 0 || tilesY === 0) {
      this.isRendering = false;
      if (this.onRenderComplete) this.onRenderComplete();
    }
  }

  _handleTileResult(data) {
    const { tileX, tileY, tileW, tileH, data: buffer } = data;
    const pixels = new Uint8ClampedArray(buffer);

    // Copy tile into render image data
    const renderW = this._renderImageData.width;
    const dstData = this._renderImageData.data;

    for (let ty = 0; ty < tileH; ty++) {
      const srcRow = ty * tileW * 4;
      const dstRow = ((tileY + ty) * renderW + tileX) * 4;
      for (let tx = 0; tx < tileW; tx++) {
        const si = srcRow + tx * 4;
        const di = dstRow + tx * 4;
        dstData[di]     = pixels[si];
        dstData[di + 1] = pixels[si + 1];
        dstData[di + 2] = pixels[si + 2];
        dstData[di + 3] = pixels[si + 3];
      }
    }

    this._completedTiles++;

    // If all tiles done, blit and move to next pass
    if (this._completedTiles >= this._pendingTiles) {
      this._blit();
      this._currentPassIndex++;
      if (this._currentPassIndex < CONFIG.progressivePasses.length) {
        requestAnimationFrame(() => this._renderPass(this.canvas.width, this.canvas.height));
      } else {
        this.isRendering = false;
        if (this.onRenderComplete) this.onRenderComplete();
      }
    }
  }

  _blit() {
    const canvasW = this.canvas.width;
    const canvasH = this.canvas.height;

    // Draw the render image data scaled to full canvas size
    this._tempCanvas.width = this._renderImageData.width;
    this._tempCanvas.height = this._renderImageData.height;
    const tempCtx = this._tempCanvas.getContext('2d');
    tempCtx.putImageData(this._renderImageData, 0, 0);

    // Disable image smoothing for crisp pixels at full resolution
    if (this._currentResolution >= 1.0) {
      this.ctx.imageSmoothingEnabled = false;
    } else {
      this.ctx.imageSmoothingEnabled = true;
      this.ctx.imageSmoothingQuality = 'medium';
    }

    this.ctx.drawImage(this._tempCanvas, 0, 0, canvasW, canvasH);
  }

  /**
   * Re-colorize the current view with a new palette or color offset.
   * Does NOT recompute iterations — only changes the color mapping.
   */
  recolor(paletteKey, colorOffset) {
    if (!this._renderImageData || this.isRendering) return;

    const data = this._renderImageData.data;
    const w = this._renderImageData.width;
    const h = this._renderImageData.height;
    const maxIter = this.viewState ? this.viewState.maxIter : 200;

    for (let i = 0; i < w * h; i++) {
      const idx = i * 4;
      // Check if interior point (black)
      if (data[idx] === 0 && data[idx+1] === 0 && data[idx+2] === 0) continue;

      // Reconstruct approximate iteration value from color — this is lossy.
      // For a proper recolor, we'd need to store iteration values separately.
      // Instead, just re-render with new palette (fast since iterations are cached in workers).
      // Actually, let's just do a full re-render for correctness.
    }

    // Full re-render is the correct approach for recoloring
    if (this.viewState) {
      this.startRender(
        this.viewState.centerRe, this.viewState.centerIm,
        this.viewState.magnification, this.viewState.maxIter,
        paletteKey, colorOffset,
        this.viewState.setMode, this.viewState.juliaCre, this.viewState.juliaCim
      );
    }
  }

  destroy() {
    this.renderId++; // cancel any in-flight work
    this.workers.forEach(w => w.terminate());
    this.workers = [];
  }
}

// ── function: buildPaletteBar ──
// ============================================================
// UI SETUP & EVENT HANDLING
// ============================================================

/**
 * Build palette swatch chips for the HUD.
 */
function buildPaletteBar(app) {
  const bar = document.getElementById('palette-bar');
  if (!bar) return;
  bar.innerHTML = '';

  PALETTE_KEYS.forEach(key => {
    const pal = PALETTES[key];
    const swatch = document.createElement('div');
    swatch.className = 'palette-swatch' + (key === app.paletteKey ? ' active' : '');
    swatch.title = pal.name;
    swatch.setAttribute('role', 'button');
    swatch.setAttribute('tabindex', '0');
    swatch.setAttribute('aria-label', `Palette: ${pal.name}`);

    // Create gradient background from palette stops
    const colors = pal.swatchColors || [];
    if (colors.length > 1) {
      swatch.style.background = `linear-gradient(90deg, ${colors.join(', ')})`;
    } else {
      swatch.style.background = '#888';
    }

    swatch.addEventListener('click', () => {
      app.paletteKey = key;
      buildPaletteBar(app);
      app.render();
    });

    swatch.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        app.paletteKey = key;
        buildPaletteBar(app);
        app.render();
      }
    });

    bar.appendChild(swatch);
  });
}

// ── function: toggleBookmarkPanel ──
/**
 * Toggle bookmark panel visibility.
 */
function toggleBookmarkPanel(app) {
  const panel = document.getElementById('bookmark-panel');
  if (!panel) return;
  const isHidden = panel.classList.contains('hidden');
  panel.classList.toggle('hidden', !isHidden);
  if (isHidden) renderBookmarkList(app);
}

// ── function: renderBookmarkList ──
/**
 * Render the bookmark list in the UI.
 */
function renderBookmarkList(app) {
  const listEl = document.getElementById('bookmark-list');
  if (!listEl) return;
  listEl.innerHTML = '';

  const bookmarks = app.loadBookmarks();
  bookmarks.forEach((b, i) => {
    const item = document.createElement('div');
    item.className = 'bookmark-item';

    const label = document.createElement('span');
    label.textContent = b.name;
    if (b.setMode === 'julia') label.textContent += ' [J]';
    item.appendChild(label);

    const delBtn = document.createElement('button');
    delBtn.textContent = '×';
    delBtn.title = 'Delete bookmark';
    delBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      app.deleteBookmark(i);
      renderBookmarkList(app);
    });
    item.appendChild(delBtn);

    item.addEventListener('click', () => {
      app.loadBookmark(i);
      // Update UI controls to match loaded state
      const iterSlider = document.getElementById('iter-slider');
      if (iterSlider && b.maxIterOverride) iterSlider.value = b.maxIterOverride;
      const colorSlider = document.getElementById('color-offset');
      if (colorSlider) colorSlider.value = b.colorOffset || 0;
      buildPaletteBar(app);
    });

    listEl.appendChild(item);
  });
}

// ── const: WORKER_SCRIPT ──
const WORKER_SCRIPT = `
var PALETTE_DATA = {};

self.onmessage = function(e) {
  var d = e.data;

  // Handle init messages (palette data)
  if (d.__init) {
    PALETTE_DATA[d.paletteKey] = d.data;
    return;
  }

  var tileX = d.tileX, tileY = d.tileY, tileW = d.tileW, tileH = d.tileH;
  var canvasW = d.canvasW, canvasH = d.canvasH;
  var centerRe = d.centerRe, centerIm = d.centerIm, magnification = d.magnification;
  var maxIter = d.maxIter, paletteKey = d.paletteKey, colorOffset = d.colorOffset;
  var setMode = d.setMode, juliaCre = d.juliaCre || 0, juliaCim = d.juliaCim || 0;

  // Precompute view dimensions
  var aspect = canvasW / canvasH;
  var viewHeight = 3.0 / magnification;
  var viewWidth = viewHeight * aspect;
  var dRe = viewWidth / canvasW;
  var dIm = viewHeight / canvasH;

  // Starting complex coordinate for this tile (top-left, y goes down)
  var startRe = centerRe + (tileX / canvasW - 0.5) * viewWidth;
  var startIm = centerIm + ((tileY + tileH) / canvasH - 0.5) * viewHeight;

  var data = new Uint8ClampedArray(tileW * tileH * 4);
  var pal = PALETTE_DATA[paletteKey];

  for (var ty = 0; ty < tileH; ty++) {
    var im = startIm - ty * dIm;
    for (var tx = 0; tx < tileW; tx++) {
      var re = startRe + tx * dRe;
      var iterVal = maxIter + 1;

      if (setMode === 'julia') {
        var zr = re, zi = im;
        var zr2 = zr * zr, zi2 = zi * zi;
        for (var n = 0; n < maxIter; n++) {
          zi = 2 * zr * zi + juliaCim;
          zr = zr2 - zi2 + juliaCre;
          zr2 = zr * zr;
          zi2 = zi * zi;
          if (zr2 + zi2 > 256) {
            var log_zn = Math.log(zr2 + zi2) * 0.5;
            iterVal = n + 1 - Math.log(log_zn) / Math.LN2;
            break;
          }
        }
      } else {
        var zr = 0, zi = 0;
        var zr2 = 0, zi2 = 0;
        for (var n = 0; n < maxIter; n++) {
          zi = 2 * zr * zi + im;
          zr = zr2 - zi2 + re;
          zr2 = zr * zr;
          zi2 = zi * zi;
          if (zr2 + zi2 > 256) {
            var log_zn = Math.log(zr2 + zi2) * 0.5;
            iterVal = n + 1 - Math.log(log_zn) / Math.LN2;
            break;
          }
        }
      }

      var idx = (ty * tileW + tx) * 4;
      if (iterVal > maxIter) {
        data[idx] = 0; data[idx+1] = 0; data[idx+2] = 0; data[idx+3] = 255;
      } else if (!pal) {
        // Fallback: simple grayscale
        var v = Math.min(255, Math.round(iterVal * 4));
        data[idx] = v; data[idx+1] = v; data[idx+2] = v; data[idx+3] = 255;
      } else {
        // Smooth coloring: normalize to palette position
        var t = (iterVal % 64) / 64.0;
        var pos;
        if (paletteKey === 'psychedelic') {
          pos = ((t + colorOffset) % 1.0 + 1.0) % 1.0;
        } else {
          pos = Math.max(0, Math.min(1, t + colorOffset));
        }

        // Find surrounding stops
        var lo = pal[0], hi = pal[pal.length - 1];
        for (var i = 0; i < pal.length - 1; i++) {
          if (pos >= pal[i][0] && pos <= pal[i+1][0]) {
            lo = pal[i]; hi = pal[i+1]; break;
          }
        }
        var range = hi[0] - lo[0];
        var frac = range > 0 ? (pos - lo[0]) / range : 0;
        data[idx]   = Math.round(lo[1] + (hi[1] - lo[1]) * frac);
        data[idx+1] = Math.round(lo[2] + (hi[2] - lo[2]) * frac);
        data[idx+2] = Math.round(lo[3] + (hi[3] - lo[3]) * frac);
        data[idx+3] = 255;
      }
    }
  }

  self.postMessage({ renderId: d.renderId, tileX, tileY, tileW, tileH, data: data.buffer }, [data.buffer]);
};
`;

// ── function: setupEvents ──
/**
 * Setup all event listeners for the fractal explorer.
 */
function setupEvents(app) {
  const canvas = app.canvas;
  const zoomOverlay = document.getElementById('zoom-overlay');
  const crosshair = document.getElementById('crosshair');

  // ---- Mouse: drag to pan, click-drag rectangle to zoom ----
  let mouseDownTime = 0;
  let mouseDownX = 0;
  let mouseDownY = 0;
  let isPanning = false;
  let isZoomSelecting = false;

  canvas.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return; // only left button
    e.preventDefault();
    app.resetHudTimer();

    mouseDownTime = Date.now();
    mouseDownX = e.offsetX;
    mouseDownY = e.offsetY;
    isPanning = false;
    isZoomSelecting = false;

    // Track for Julia preview in Mandelbrot mode
    if (app.setMode === 'mandelbrot' && app.juliaPreviewMode) {
      const c = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
      app._previewJuliaCre = c.re;
      app._previewJuliaCim = c.im;
    }
  });

  canvas.addEventListener('mousemove', (e) => {
    if (!app.hudVisible && !isPanning && !isZoomSelecting) return;

    const dx = e.offsetX - mouseDownX;
    const dy = e.offsetY - mouseDownY;
    const dist = Math.sqrt(dx * dx + dy * dy);

    // Julia preview on hover (Mandelbrot mode only, not during drag)
    if (app.setMode === 'mandelbrot' && app.juliaPreviewMode && !isPanning && !isZoomSelecting) {
      const c = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
      if (app._previewJuliaCre !== c.re || app._previewJuliaCim !== c.im) {
        app._previewJuliaCre = c.re;
        app._previewJuliaCim = c.im;
      }
    }

    if (dist < CONFIG.panDragThreshold && Date.now() - mouseDownTime > 200) {
      // Long press without much movement → start zoom selection
      isZoomSelecting = true;
      canvas.style.cursor = 'crosshair';
      return;
    }

    if (dist >= CONFIG.panDragThreshold || isPanning) {
      isPanning = true;
      isZoomSelecting = false;
      canvas.style.cursor = 'grabbing';

      // Pan: convert pixel delta to complex plane delta
      const aspect = canvas.width / canvas.height;
      const viewHeight = 3.0 / app.magnification;
      const dRe = -dx / canvas.width * viewHeight * aspect;
      const dIm = dy / canvas.height * viewHeight;

      app.centerRe += dRe;
      app.centerIm += dIm;
    }

    if (isZoomSelecting) {
      // Update zoom overlay rectangle
      const x1 = Math.min(mouseDownX, e.offsetX);
      const y1 = Math.min(mouseDownY, e.offsetY);
      const w = Math.abs(e.offsetX - mouseDownX);
      const h = Math.abs(e.offsetY - mouseDownY);

      zoomOverlay.style.display = 'block';
      zoomOverlay.style.left = x1 + 'px';
      zoomOverlay.style.top = y1 + 'px';
      zoomOverlay.style.width = w + 'px';
      zoomOverlay.style.height = h + 'px';
    }

    app.render();
  });

  canvas.addEventListener('mouseup', (e) => {
    if (isZoomSelecting && Math.abs(e.offsetX - mouseDownX) > CONFIG.dragZoomMinPixels) {
      // Zoom into the selected rectangle
      const c1 = pixelToComplex(mouseDownX, mouseDownY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
      const c2 = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);

      // Save current view for back button
      app.zoomHistory.push({
        centerRe: app.centerRe,
        centerIm: app.centerIm,
        magnification: app.magnification,
      });

      const newCenterRe = (c1.re + c2.re) / 2;
      const newCenterIm = (c1.im + c2.im) / 2;
      const width = Math.abs(c2.re - c1.re);
      const height = Math.abs(c2.im - c1.im);

      // Compute new magnification based on the larger dimension of the selection
      const aspect = canvas.width / canvas.height;
      let newMag;
      if (width / height > aspect) {
        newMag = app.magnification * ((3.0 / app.magnification) * aspect / width);
      } else {
        newMag = app.magnification * ((3.0 / app.magnification) / height);
      }

      app.centerRe = newCenterRe;
      app.centerIm = newCenterIm;
      app.magnification = Math.min(newMag, CONFIG.maxMagnification);
    } else if (isPanning && app.setMode === 'mandelbrot') {
      // Single click in Mandelbrot mode → enter Julia mode for that point
      const c = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
      app.enterJuliaMode(c.re, c.im);
    }

    isPanning = false;
    isZoomSelecting = false;
    zoomOverlay.style.display = 'none';
    canvas.style.cursor = 'crosshair';
    updateCrosshair(app);
    app.render();
  });

  // ---- Mouse wheel: zoom toward cursor ----
  canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    app.resetHudTimer();

    const factor = Math.exp(-e.deltaY * CONFIG.zoomSpeed / 100);
    app.zoomAt(e.offsetX, e.offsetY, factor);
  }, { passive: false });

  // ---- Keyboard controls ----
  document.addEventListener('keydown', (e) => {
    if (e.target.tagName === 'INPUT') return; // don't intercept typing in inputs

    const panAmount = 0.1 / app.magnification;

    switch(e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        app.centerRe -= panAmount;
        app.render();
        break;
      case 'ArrowRight':
        e.preventDefault();
        app.centerRe += panAmount;
        app.render();
        break;
      case 'ArrowUp':
        e.preventDefault();
        app.centerIm -= panAmount;
        app.render();
        break;
      case 'ArrowDown':
        e.preventDefault();
        app.centerIm += panAmount;
        app.render();
        break;
      case '+':
      case '=':
        e.preventDefault();
        app.zoomAt(canvas.width / 2, canvas.height / 2, 1.5);
        break;
      case '-':
      case '_':
        e.preventDefault();
        app.zoomAt(canvas.width / 2, canvas.height / 2, 1 / 1.5);
        break;
      case 'z':
      case 'Z':
        e.preventDefault();
        app.goBack();
        break;
      case 'r':
      case 'R':
        e.preventDefault();
        app.resetView();
        buildPaletteBar(app);
        document.getElementById('iter-slider').value = CONFIG.baseIterations;
        updateCrosshair(app);
        break;
      case 'j':
      case 'J':
        e.preventDefault();
        if (app.setMode === 'mandelbrot') {
          app.enterJuliaMode(app.juliaCre, app.juliaCim);
        } else {
          // Return to Mandelbrot view at the c-value location
          app.zoomHistory.push({
            centerRe: app.centerRe,
            centerIm: app.centerIm,
            magnification: app.magnification,
          });
          app.setMode = 'mandelbrot';
          app.centerRe = app.juliaCre;
          app.centerIm = app.juliaCim;
          app.render();
        }
        buildPaletteBar(app);
        updateCrosshair(app);
        break;
      case 'b':
      case 'B':
        e.preventDefault();
        toggleBookmarkPanel(app);
        break;
    }

    app.resetHudTimer();
  });

  // ---- HUD buttons ----
  document.getElementById('btn-reset').addEventListener('click', () => {
    app.resetView();
    buildPaletteBar(app);
    const iterSlider = document.getElementById('iter-slider');
    if (iterSlider) iterSlider.value = CONFIG.baseIterations;
    updateCrosshair(app);
  });

  document.getElementById('btn-back').addEventListener('click', () => {
    app.goBack();
  });

  document.getElementById('btn-julia').addEventListener('click', () => {
    if (app.setMode === 'mandelbrot') {
      app.enterJuliaMode(app.juliaCre, app.juliaCim);
    } else {
      app.zoomHistory.push({
        centerRe: app.centerRe,
        centerIm: app.centerIm,
        magnification: app.magnification,
      });
      app.setMode = 'mandelbrot';
      app.centerRe = app.juliaCre;
      app.centerIm = app.juliaCim;
      app.render();
    }
    buildPaletteBar(app);
    updateCrosshair(app);
  });

  // ---- Sliders ----
  const iterSlider = document.getElementById('iter-slider');
  if (iterSlider) {
    iterSlider.addEventListener('input', () => {
      app.maxIterOverride = parseInt(iterSlider.value);
      app.render();
    });
  }

  const colorOffsetSlider = document.getElementById('color-offset');
  if (colorOffsetSlider) {
    colorOffsetSlider.addEventListener('input', () => {
      app.colorOffset = parseFloat(colorOffsetSlider.value);
      app.render();
    });
  }

  // ---- Bookmark panel ----
  document.getElementById('btn-save-bookmark').addEventListener('click', () => {
    const nameInput = document.getElementById('bookmark-name');
    const name = nameInput.value.trim() || null;
    app.saveBookmark(name);
    nameInput.value = '';
    renderBookmarkList(app);
  });

  document.getElementById('btn-close-bookmarks').addEventListener('click', () => {
    toggleBookmarkPanel(app);
  });

  // ---- Mouse move for HUD visibility ----
  canvas.addEventListener('mousemove', () => app.resetHudTimer());

  // Initial crosshair state
  updateCrosshair(app);
}

// ── function: updateCrosshair ──
/**
 * Show/hide the crosshair based on current mode.
 */
function updateCrosshair(app) {
  const crosshair = document.getElementById('crosshair');
  if (!crosshair) return;
  if (app.setMode === 'julia') {
    crosshair.classList.remove('hidden');
  } else {
    crosshair.classList.add('hidden');
  }
}

// ── class: FractalApp ──
// ============================================================
// APPLICATION STATE & NAVIGATION
// ============================================================

class FractalApp {
  constructor(canvas) {
    this.canvas = canvas;
    this.renderer = new FractalRenderer(canvas);

    // View state
    this.centerRe = -0.5;
    this.centerIm = 0;
    this.magnification = 1;
    this.maxIterOverride = null;
    this.paletteKey = 'fire';
    this.colorOffset = 0;
    this.setMode = 'mandelbrot'; // 'mandelbrot' or 'julia'
    this.juliaCre = -0.7269;
    this.juliaCim = 0.1889;

    // Navigation state
    this.zoomHistory = [];
    this.isDragging = false;
    this.dragStartX = 0;
    this.dragStartY = 0;
    this.dragMode = null; // 'pan' or 'zoom'
    this._lastMouseX = 0;
    this._lastMouseY = 0;

    // FPS tracking
    this._frameTimes = [];
    this._fps = 60;

    // HUD auto-hide
    this._hudTimer = null;
    this.hudVisible = true;

    // Julia preview mode
    this.juliaPreviewMode = false;

    // Setup renderer callback
    this.renderer.onRenderComplete = () => {
      if (this.onRenderComplete) this.onRenderComplete();
    };

    // Resize handler
    this._resizeObserver = new ResizeObserver(() => this.resize());
    this._resizeObserver.observe(this.canvas.parentElement || document.body);
  }

  resize() {
    const parent = this.canvas.parentElement;
    if (!parent) return;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    this.canvas.width = parent.clientWidth * dpr;
    this.canvas.height = parent.clientHeight * dpr;
    this._needsRender = true;
    this.render();
  }

  getEffectiveMaxIter() {
    return computeIterations(this.magnification, this.maxIterOverride);
  }

  render() {
    const maxIter = this.getEffectiveMaxIter();
    this.renderer.startRender(
      this.centerRe, this.centerIm,
      Math.min(this.magnification, CONFIG.maxMagnification),
      maxIter,
      this.paletteKey,
      this.colorOffset,
      this.setMode,
      this.juliaCre,
      this.juliaCim
    );
  }

  setView({ re, im, scale }) {
    if (re !== undefined) this.centerRe = re;
    if (im !== undefined) this.centerIm = im;
    if (scale !== undefined) this.magnification = Math.min(scale, CONFIG.maxMagnification);
    this.render();
  }

  zoomToRegion(re1, im1, re2, im2) {
    this.zoomHistory.push({
      centerRe: this.centerRe,
      centerIm: this.centerIm,
      magnification: this.magnification,
    });

    const newCenterRe = (re1 + re2) / 2;
    const newCenterIm = (im1 + im2) / 2;
    const width = Math.abs(re2 - re1);
    const height = Math.abs(im2 - im1);

    const aspect = this.canvas.width / this.canvas.height;
    let newMag;
    if (width / height > aspect) {
      newMag = this.magnification * ((3.0 / this.magnification) * aspect / width);
    } else {
      newMag = this.magnification * ((3.0 / this.magnification) / height);
    }

    this.centerRe = newCenterRe;
    this.centerIm = newCenterIm;
    this.magnification = Math.min(newMag, CONFIG.maxMagnification);
    this.render();
  }

  zoomAt(px, py, factor) {
    const c = pixelToComplex(px, py, this.canvas.width, this.canvas.height, this.centerRe, this.centerIm, this.magnification);

    if (Math.abs(factor - 1.0) > 0.05) {
      this.zoomHistory.push({
        centerRe: this.centerRe,
        centerIm: this.centerIm,
        magnification: this.magnification,
      });
    }

    const newMag = Math.min(this.magnification * factor, CONFIG.maxMagnification);

    const aspect = this.canvas.width / this.canvas.height;
    const viewHeightOld = 3.0 / this.magnification;
    const viewWidthOld = viewHeightOld * aspect;
    const viewHeightNew = 3.0 / newMag;
    const viewWidthNew = viewHeightNew * aspect;

    const fracX = (c.re - this.centerRe) / viewWidthOld;
    const fracY = (c.im - this.centerIm) / viewHeightOld;

    this.centerRe = c.re - fracX * viewWidthNew;
    this.centerIm = c.im - fracY * viewHeightNew;
    this.magnification = newMag;
    this.render();
  }

  panBy(dRe, dIm) {
    this.centerRe += dRe;
    this.centerIm += dIm;
    this.render();
  }

  goBack() {
    if (this.zoomHistory.length === 0) return false;
    const prev = this.zoomHistory.pop();
    this.centerRe = prev.centerRe;
    this.centerIm = prev.centerIm;
    this.magnification = prev.magnification;
    this.render();
    return true;
  }

  resetView() {
    this.zoomHistory.push({
      centerRe: this.centerRe,
      centerIm: this.centerIm,
      magnification: this.magnification,
    });
    this.centerRe = -0.5;
    this.centerIm = 0;
    this.magnification = 1;
    this.setMode = 'mandelbrot';
    this.render();
  }

  enterJuliaMode(cre, cim) {
    this.zoomHistory.push({
      centerRe: this.centerRe,
      centerIm: this.centerIm,
      magnification: this.magnification,
    });
    this.setMode = 'julia';
    this.juliaCre = cre;
    this.juliaCim = cim;
    this.centerRe = 0;
    this.centerIm = 0;
    this.magnification = Math.max(1, this.magnification * 0.5);
    this.render();
  }

  iterationsAt(re, im) {
    const maxIter = this.getEffectiveMaxIter();
    return mandelbrotIteration(re, im, maxIter);
  }

  updateHUD() {
    // Magnification with precision warning
    const magEl = document.getElementById('mag-display');
    if (magEl) {
      const m = this.magnification;
      let text;
      if (m >= 1e6) text = `×${m.toExponential(2)}`;
      else if (m >= 100) text = `×${Math.round(m).toLocaleString()}`;
      else text = `×${m.toFixed(1)}`;

      // Warn near precision limit
      if (m > CONFIG.maxMagnification * 0.5) {
        text += ' ⚠';
      }
      magEl.textContent = text;
    }

    // Center coordinates with adaptive precision
    const centerEl = document.getElementById('center-display');
    if (centerEl) {
      const prec = Math.min(12, Math.max(6, Math.floor(Math.log10(this.magnification)) + 2));
      centerEl.textContent = `(${this.centerRe.toFixed(prec)}, ${this.centerIm.toFixed(prec)})`;
    }

    // Iterations
    const iterEl = document.getElementById('iter-display');
    if (iterEl) {
      iterEl.textContent = `Iter: ${this.getEffectiveMaxIter()}`;
    }

    // FPS
    const fpsEl = document.getElementById('fps-display');
    if (fpsEl) {
      fpsEl.textContent = `${Math.round(this._fps)} FPS`;
    }

    // Mode display
    const modeEl = document.getElementById('mode-display');
    if (modeEl) {
      if (this.setMode === 'julia') {
        modeEl.textContent = `Julia c=${this.juliaCre.toFixed(4)}+${this.juliaCim.toFixed(4)}i`;
        modeEl.style.color = '#f9a';
      } else {
        modeEl.textContent = 'Mandelbrot';
        modeEl.style.color = '#7df';
      }
    }

    // Julia mode button state
    const juliaBtn = document.getElementById('btn-julia');
    if (juliaBtn) {
      juliaBtn.classList.toggle('active', this.setMode === 'julia');
    }

    // Back button visibility
    const backBtn = document.getElementById('btn-back');
    if (backBtn) {
      backBtn.classList.toggle('hidden', this.zoomHistory.length === 0);
    }

    // Julia preview label
    const juliaLabel = document.getElementById('julia-preview-label');
    if (juliaLabel) {
      juliaLabel.classList.toggle('hidden', !this.juliaPreviewMode);
      const cDisplay = document.getElementById('julia-c-display');
      if (cDisplay && this.juliaPreviewMode) {
        cDisplay.textContent = `${this._previewJuliaCre.toFixed(6)} + ${this._previewJuliaCim.toFixed(6)}i`;
      }
    }
  }

  saveBookmark(name) {
    const bookmarks = this.loadBookmarks();
    const state = {
      name: name || `View ${bookmarks.length + 1}`,
      centerRe: this.centerRe,
      centerIm: this.centerIm,
      magnification: this.magnification,
      maxIterOverride: this.maxIterOverride,
      paletteKey: this.paletteKey,
      colorOffset: this.colorOffset,
      setMode: this.setMode,
      juliaCre: this.juliaCre,
      juliaCim: this.juliaCim,
    };
    bookmarks.push(state);
    try { localStorage.setItem('fractal_bookmarks', JSON.stringify(bookmarks)); } catch(e) {}
    return state;
  }

  loadBookmark(index) {
    const bookmarks = this.loadBookmarks();
    if (index < 0 || index >= bookmarks.length) return null;
    const b = bookmarks[index];
    this.centerRe = b.centerRe;
    this.centerIm = b.centerIm;
    this.magnification = Math.min(b.magnification, CONFIG.maxMagnification);
    this.maxIterOverride = b.maxIterOverride || null;
    this.paletteKey = b.paletteKey || 'fire';
    this.colorOffset = b.colorOffset || 0;
    this.setMode = b.setMode || 'mandelbrot';
    this.juliaCre = b.juliaCre || -0.7269;
    this.juliaCim = b.juliaCim || 0.1889;
    this.render();
    return b;
  }

  loadBookmarks() {
    try {
      const data = localStorage.getItem('fractal_bookmarks');
      return data ? JSON.parse(data) : [];
    } catch(e) { return []; }
  }

  deleteBookmark(index) {
    const bookmarks = this.loadBookmarks();
    if (index >= 0 && index < bookmarks.length) {
      bookmarks.splice(index, 1);
      try { localStorage.setItem('fractal_bookmarks', JSON.stringify(bookmarks)); } catch(e) {}
    }
  }

  saveToHash() {
    const state = {
      re: this.centerRe, im: this.centerIm, m: this.magnification,
      p: this.paletteKey, c: this.colorOffset, s: this.setMode,
      jc: this.juliaCre, ji: this.juliaCim, i: this.maxIterOverride,
    };
    const encoded = btoa(JSON.stringify(state));
    window.location.hash = encoded;
  }

  loadFromHash() {
    if (!window.location.hash || window.location.hash.length < 2) return false;
    try {
      const decoded = JSON.parse(atob(window.location.hash.substring(1)));
      this.centerRe = decoded.re;
      this.centerIm = decoded.im;
      this.magnification = Math.min(decoded.m, CONFIG.maxMagnification);
      this.paletteKey = decoded.p || 'fire';
      this.colorOffset = decoded.c || 0;
      this.setMode = decoded.s || 'mandelbrot';
      this.juliaCre = decoded.jc || -0.7269;
      this.juliaCim = decoded.ji || 0.1889;
      this.maxIterOverride = decoded.i !== undefined ? decoded.i : null;
      return true;
    } catch(e) { return false; }
  }

  trackFrame() {
    const now = performance.now();
    this._frameTimes.push(now);
    while (this._frameTimes.length > CONFIG.fpsWindowFrames) {
      this._frameTimes.shift();
    }
    if (this._frameTimes.length >= 2) {
      const elapsed = now - this._frameTimes[0];
      this._fps = (this._frameTimes.length - 1) / (elapsed / 1000);
    }
  }

  resetHudTimer() {
    if (this._hudTimer) clearTimeout(this._hudTimer);
    const hud = document.getElementById('hud');
    if (hud) hud.classList.remove('hidden-hud');
    this.hudVisible = true;

    this._hudTimer = setTimeout(() => {
      if (!this.isDragging) {
        if (hud) hud.classList.add('hidden-hud');
        this.hudVisible = false;
      }
    }, CONFIG.hudHideDelay);
  }

  destroy() {
    this.renderer.destroy();
    if (this._resizeObserver) this._resizeObserver.disconnect();
    if (this._hudTimer) clearTimeout(this._hudTimer);
  }
}

// ── main ──
// ============================================================
// MAIN — Initialization & Debug Interface
// ============================================================
(function() {
  const canvas = document.getElementById('fractal-canvas');
  if (!canvas) { console.error('Canvas not found!'); return; }

  // Create the app instance
  const app = new FractalApp(canvas);

  // Load state from URL hash if present, otherwise use defaults
  const loadedFromHash = app.loadFromHash();

  // Initial resize and render
  app.resize();

  // Setup UI
  buildPaletteBar(app);
  setupEvents(app);

  // Update iteration slider to match current effective iterations
  const iterSlider = document.getElementById('iter-slider');
  if (iterSlider) {
    iterSlider.value = app.maxIterOverride || CONFIG.baseIterations;
  }

  // Color offset slider
  const colorOffsetSlider = document.getElementById('color-offset');
  if (colorOffsetSlider) {
    colorOffsetSlider.value = app.colorOffset;
  }

  // Update HUD periodically
  function updateHUDLoop() {
    app.updateHUD();
    app.trackFrame();
    requestAnimationFrame(updateHUDLoop);
  }
  requestAnimationFrame(updateHUDLoop);

  // Expose debug interface on window
  window.fractal = {
    /** Get iteration count at a Mandelbrot point */
    iterationsAt: function(re, im) {
      return app.iterationsAt(re, im);
    },
    /** Set view parameters directly */
    setView: function(params) {
      app.setView(params);
    },
    /** Get current view state */
    getViewState: function() {
      return {
        centerRe: app.centerRe,
        centerIm: app.centerIm,
        magnification: app.magnification,
        maxIter: app.getEffectiveMaxIter(),
        paletteKey: app.paletteKey,
        colorOffset: app.colorOffset,
        setMode: app.setMode,
        juliaCre: app.juliaCre,
        juliaCim: app.juliaCim,
      };
    },
    /** Get the raw renderer */
    getRenderer: function() { return app.renderer; },
    /** Get the canvas element */
    getCanvas: function() { return canvas; },
    /** Full app instance for advanced debugging */
    _app: app,
  };

  // Log debug info
  console.log('%c Fractal Explorer Ready ', 'background:#333;color:#0f0;padding:4px 8px;border-radius:4px;font-weight:bold');
  console.log('Debug interface available at window.fractal');
  console.log('Try: fractal.iterationsAt(0, 0) → should be maxIter+1 (interior)');
  console.log('     fractal.iterationsAt(-1, 0) → should be maxIter+1 (interior)');
  console.log('     fractal.iterationsAt(2, 0) → should escape in ≤3 iterations');
  console.log('     fractal.setView({re: -0.75, im: 0.1, scale: 1000})');

})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"743626f3-1831-4335-8234-7544da86b04e","tokensIn":1881234,"tokensOut":40915,"tokensTotal":1922149,"turns":52,"toolCalls":51,"failedToolCalls":0,"timestamp":"2026-07-30T03:39:20.255Z"} -->