← Fractal Explorer results

Fractal Explorer

Qwen 3.8 · typescript

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 81,166 bytes · SHA-256 241853721d5c
<!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>
/* ── css: base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; background: #000; }
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #e8e8f0; user-select: none; -webkit-user-select: none; }
#app { position: fixed; inset: 0; }
canvas#view { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: crosshair; }
.hidden { display: none !important; }
.glass {
  background: rgba(16, 18, 28, 0.55);
  backdrop-filter: blur(10px) saturate(1.3);
  -webkit-backdrop-filter: blur(10px) saturate(1.3);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 10px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
}
kbd {
  display: inline-block; padding: 1px 5px; margin: 0 1px;
  background: rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 4px; font-size: 10.5px; font-family: inherit;
}
/* ── css: hud ── */
#hud {
  position: absolute; top: 14px; left: 14px;
  padding: 10px 14px; min-width: 230px;
  transition: opacity 0.45s ease, transform 0.45s ease;
  pointer-events: none;
}
#hud.idle { opacity: 0; transform: translateY(-8px); }
#hud .big { font-size: 22px; font-weight: 700; letter-spacing: 0.5px; color: #fff; }
#hud .row { font-size: 11.5px; margin-top: 4px; color: #cfd2e4; display: flex; align-items: baseline; }
#hud .label { color: #8b90ad; text-transform: uppercase; font-size: 9.5px; letter-spacing: 1px; }
#hud .gap { width: 12px; }
#hud span:not(.label):not(.gap) { color: #eef0ff; }

#modeBadge {
  position: absolute; top: 14px; right: 14px;
  padding: 8px 14px; font-size: 12.5px; letter-spacing: 1px; font-weight: 600;
  transition: opacity 0.45s ease;
}
#modeBadge.idle { opacity: 0; }
#modeBadge.julia { color: #7fd7ff; border-color: rgba(127, 215, 255, 0.35); }

#toast {
  position: absolute; bottom: 84px; left: 50%; transform: translateX(-50%) translateY(8px);
  padding: 8px 16px; font-size: 12px; opacity: 0; pointer-events: none;
  transition: opacity 0.3s ease, transform 0.3s ease; max-width: 80vw;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

#zoomRect {
  position: absolute; display: none;
  border: 1.5px solid #ffffff;
  background: rgba(255, 255, 255, 0.06);
  mix-blend-mode: difference;
  pointer-events: none;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
}
/* ── css: dock ── */
#dock {
  position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 8px;
  padding: 9px 14px; flex-wrap: wrap; justify-content: center;
  max-width: calc(100vw - 24px);
  transition: opacity 0.45s ease, transform 0.45s ease;
}
#dock.idle { opacity: 0; transform: translateX(-50%) translateY(10px); pointer-events: none; }
.btn {
  font-family: inherit; font-size: 11.5px; letter-spacing: 0.3px;
  color: #e8e8f0; background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 7px;
  padding: 6px 10px; cursor: pointer; transition: background 0.15s, border-color 0.15s;
}
.btn:hover { background: rgba(255, 255, 255, 0.18); }
.btn:focus-visible { outline: 2px solid #7fd7ff; outline-offset: 1px; }
.btn.active { background: rgba(127, 215, 255, 0.22); border-color: rgba(127, 215, 255, 0.5); color: #d8f2ff; }
.divider { width: 1px; height: 22px; background: rgba(255, 255, 255, 0.18); }
.lbl { font-size: 9.5px; text-transform: uppercase; letter-spacing: 1px; color: #8b90ad; }
#dock input[type=range] { width: 92px; accent-color: #7fd7ff; cursor: pointer; }
.auto { display: flex; align-items: center; gap: 4px; font-size: 10px; color: #8b90ad; cursor: pointer; }
.auto input { accent-color: #7fd7ff; cursor: pointer; }
.chip {
  width: 26px; height: 26px; border-radius: 7px; cursor: pointer;
  border: 2px solid rgba(255, 255, 255, 0.18); padding: 0;
  transition: transform 0.12s, border-color 0.15s;
}
.chip:hover { transform: scale(1.12); }
.chip.active { border-color: #fff; box-shadow: 0 0 10px rgba(255, 255, 255, 0.45); }
.chip:focus-visible { outline: 2px solid #7fd7ff; outline-offset: 2px; }
/* ── css: panels ── */
#previewWrap {
  position: absolute; right: 14px; bottom: 90px;
  width: 172px; padding: 9px; text-align: center;
  transition: opacity 0.3s ease;
}
#preview { width: 100%; height: auto; display: block; border-radius: 6px; image-rendering: auto; }
#previewWrap .crosshair {
  position: absolute; left: 50%; top: 50%; width: 44%; height: 44%;
  transform: translate(-50%, -50%); pointer-events: none;
}
#previewWrap .crosshair::before, #previewWrap .crosshair::after {
  content: ""; position: absolute; background: rgba(127, 215, 255, 0.75);
  box-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
#previewWrap .crosshair::before { left: 50%; top: 0; width: 1px; height: 100%; }
#previewWrap .crosshair::after { top: 50%; left: 0; height: 1px; width: 100%; }
#previewLabel { font-size: 9.5px; letter-spacing: 1.5px; text-transform: uppercase; color: #9aa0c0; margin-top: 6px; }

#bookmarks {
  position: absolute; right: 14px; bottom: 90px;
  width: 272px; padding: 10px 12px; z-index: 5;
  display: none; flex-direction: column; gap: 8px;
}
#bookmarks.open { display: flex; }
.bmhead { display: flex; gap: 6px; }
.bmhead input {
  flex: 1; font-family: inherit; font-size: 11.5px; color: #e8e8f0;
  background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 6px; padding: 6px 8px;
}
#bmList { display: flex; flex-direction: column; gap: 5px; max-height: 40vh; overflow-y: auto; }
.bmRow {
  display: flex; align-items: center; gap: 6px; font-size: 11px;
  background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 6px; padding: 5px 7px;
}
.bmRow .name { flex: 1; cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bmRow .name:hover { color: #7fd7ff; }
.bmRow .meta { color: #8b90ad; font-size: 9.5px; }
.bmRow .del { background: none; border: none; color: #a0a6c4; cursor: pointer; font-size: 12px; padding: 2px 4px; }
.bmRow .del:hover { color: #ff7d7d; }
.bmhint { font-size: 9.5px; color: #6b7090; }

#help {
  position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
  background: rgba(0, 0, 0, 0.45); z-index: 10;
}
#help.open { display: flex; }
#help .panel { max-width: 520px; width: calc(100vw - 48px); padding: 20px 24px; }
#help h1 { font-size: 16px; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; color: #fff; }
#help table { width: 100%; border-collapse: collapse; font-size: 11.5px; }
#help td { padding: 4px 6px; vertical-align: top; }
#help td:first-child { white-space: nowrap; width: 1%; text-align: right; padding-right: 12px; }
.closeRow { margin-top: 14px; text-align: right; }
</style>
</head>
<body>
<div id="app"><canvas id="view" aria-label="Fractal canvas"></canvas>
<div id="hud" class="glass" role="status" aria-live="off">
  <div class="big" id="hudMag">×1</div>
  <div class="row"><span class="label">re</span> <span id="hudRe">–</span><span class="gap"></span><span class="label">im</span> <span id="hudIm">–</span></div>
  <div class="row"><span class="label">iters</span> <span id="hudIters">–</span><span class="gap"></span><span class="label">fps</span> <span id="hudFps">–</span></div>
</div>
<div id="modeBadge" class="glass">Mandelbrot</div>
<div id="dock" class="glass" role="toolbar" aria-label="Controls">
  <button class="btn" id="btnBack" title="Zoom history back (B)">⟵ Back</button>
  <button class="btn" id="btnReset" title="Reset view (R)">Reset</button>
  <span class="divider" aria-hidden="true"></span>
  <button class="btn" id="btnDrag" title="Toggle left-drag behavior">Drag: ZoomBox</button>
  <button class="btn" id="btnJulia" title="Open/close Julia set (J)">Julia</button>
  <button class="btn" id="btnBookmarks" title="Bookmarks (M)">Bookmarks</button>
  <button class="btn" id="btnHelp" title="Help (H)">?</button>
  <span class="divider" aria-hidden="true"></span>
  <button class="chip" id="chipFire" data-pal="fire" title="Fire palette (1)" aria-label="Fire palette"></button>
  <button class="chip" id="chipOcean" data-pal="ocean" title="Ocean palette (2)" aria-label="Ocean palette"></button>
  <button class="chip" id="chipMono" data-pal="mono" title="Monochrome palette (3)" aria-label="Monochrome palette"></button>
  <button class="chip" id="chipPsy" data-pal="psy" title="Psychedelic palette (4)" aria-label="Psychedelic palette"></button>
  <span class="divider" aria-hidden="true"></span>
  <label class="lbl" for="offsetRange">drift</label>
  <input type="range" id="offsetRange" min="0" max="1000" value="350" aria-label="Palette offset (color drift)">
  <span class="divider" aria-hidden="true"></span>
  <label class="lbl" for="itersRange">iters</label>
  <input type="range" id="itersRange" min="0" max="1000" value="500" aria-label="Iteration override">
  <label class="auto"><input type="checkbox" id="itersAuto" checked=""> auto</label>
</div>
<div id="previewWrap" class="glass hidden">
  <canvas id="preview" width="144" height="144" aria-label="Julia set preview"></canvas>
  <div class="crosshair" aria-hidden="true"></div>
  <div id="previewLabel">Julia preview</div>
</div>
<div id="bookmarks" class="glass">
  <div class="bmhead">
    <input id="bmName" placeholder="Bookmark name…" aria-label="Bookmark name">
    <button class="btn" id="bmSave">Save</button>
  </div>
  <div id="bmList"></div>
  <div class="bmhint">Views are stored in localStorage.</div>
</div>
<div id="toast" class="glass" role="status"></div>
<div id="help">
  <div class="panel glass">
    <h1>Fractal Explorer</h1>
    <table>
      <tbody><tr><td><kbd>Left-drag</kbd></td><td>Zoom box (or pan, see <kbd>Drag</kbd> toggle)</td></tr>
      <tr><td><kbd>Middle / right-drag</kbd>, <kbd>Space</kbd>+drag</td><td>Pan</td></tr>
      <tr><td><kbd>Wheel</kbd></td><td>Zoom toward cursor</td></tr>
      <tr><td><kbd>Click</kbd> (no drag)</td><td>Open the Julia set for that c</td></tr>
      <tr><td><kbd>←</kbd> <kbd>↑</kbd> <kbd>→</kbd> <kbd>↓</kbd></td><td>Pan</td></tr>
      <tr><td><kbd>+</kbd> / <kbd>−</kbd></td><td>Zoom in / out</td></tr>
      <tr><td><kbd>B</kbd> · <kbd>R</kbd></td><td>Back · Reset</td></tr>
      <tr><td><kbd>J</kbd></td><td>Toggle Julia mode</td></tr>
      <tr><td><kbd>1</kbd>–<kbd>4</kbd></td><td>Palettes</td></tr>
      <tr><td><kbd>[</kbd> / <kbd>]</kbd></td><td>Fewer / more iterations</td></tr>
      <tr><td><kbd>M</kbd> · <kbd>H</kbd> · <kbd>Esc</kbd></td><td>Bookmarks · Help · Close / exit Julia</td></tr>
    </tbody></table>
    <div class="closeRow"><button class="btn" id="btnCloseHelp">Close</button></div>
  </div>
</div>
<div id="zoomRect" aria-hidden="true"></div></div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./config":"src/config.ts","./core":"src/core.ts","./palettes":"src/palettes.ts","./renderer":"src/renderer.ts","./state":"src/state.ts","./interact":"src/interact.ts","./ui":"src/ui.ts"},"src/palettes.ts":{"./config":"src/config.ts"},"src/core.ts":{"./config":"src/config.ts"},"src/renderer.ts":{"./config":"src/config.ts","./core":"src/core.ts","./palettes":"src/palettes.ts"},"src/state.ts":{"./config":"src/config.ts","./core":"src/core.ts","./palettes":"src/palettes.ts"},"src/interact.ts":{"./config":"src/config.ts","./core":"src/core.ts","./state":"src/state.ts"},"src/ui.ts":{"./config":"src/config.ts","./core":"src/core.ts","./state":"src/state.ts"}};
function __require(id) {
  if (__cache[id]) return __cache[id].exports;
  var module = __cache[id] = { exports: {} };
  var factory = __mods[id];
  if (!factory) throw new Error("Module not found: " + id);
  factory(module.exports, function (spec) {
    var target = (__map[id] && __map[id][spec]) || spec;
    return __require(target);
  }, module);
  return module.exports;
}

// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// MAIN — wiring: canvas, loop, actions, dock, debug interface
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
const config_1 = require("./config");
const core_1 = require("./core");
const palettes_1 = require("./palettes");
const renderer_1 = require("./renderer");
const state_1 = require("./state");
const interact_1 = require("./interact");
const ui_1 = require("./ui");
// ---------------- canvas ----------------
const canvas = document.getElementById('view');
if (!canvas)
    throw new Error('#view canvas missing');
const zoomRect = document.getElementById('zoomRect');
const renderer = new renderer_1.Renderer();
const ui = new ui_1.UI(applyViewState);
let dpr = window.devicePixelRatio || 1;
let syncItersUI = () => { };
let driftForced = false;
function resize() {
    dpr = window.devicePixelRatio || 1;
    core_1.view.width = window.innerWidth;
    core_1.view.height = window.innerHeight;
    renderer.ensureSize(core_1.view.width, core_1.view.height, dpr, canvas);
    renderer.invalidate('coarse');
}
window.addEventListener('resize', resize);
resize();
// ---------------- palette LUT sync ----------------
function syncLuts() {
    (0, ui_1.setPreviewLut)(palettes_1.palette.lut);
}
(0, palettes_1.paletteSet)('fire');
syncLuts();
ui.setPaletteChips(palettes_1.palette.name);
ui.wirePaletteChips((n) => setPalette(n));
ui.wireHelpClose();
// ---------------- core action helpers ----------------
let lastMarkedRe = core_1.view.re;
let lastMarkedIm = core_1.view.im;
let lastMarkedMode = state_1.state.mode;
function markDirty() {
    const dxPx = Math.abs(core_1.view.re - lastMarkedRe) / core_1.view.unitsPerPixel;
    const dyPx = Math.abs(core_1.view.im - lastMarkedIm) / core_1.view.unitsPerPixel;
    const big = state_1.state.mode !== lastMarkedMode || dxPx > 48 || dyPx > 48;
    renderer.mode = state_1.state.mode;
    renderer.cRe = state_1.state.cRe;
    renderer.cIm = state_1.state.cIm;
    renderer.maxIter = (0, state_1.currentMaxIter)();
    renderer.pal = palettes_1.palette;
    renderer.invalidate(big ? 'coarse' : 'fine');
    lastMarkedRe = core_1.view.re;
    lastMarkedIm = core_1.view.im;
    lastMarkedMode = state_1.state.mode;
    ui.poke();
}
let warnedPrecision = false;
function checkPrecision() {
    if ((0, core_1.mag)() > config_1.CFG.warnMagnitude && !warnedPrecision) {
        warnedPrecision = true;
        ui.toast(`Precision limit: double precision degrades beyond ×${(0, ui_1.fmtMag)(config_1.CFG.maxMagnification)}. Zoom is capped here.`, 4200);
    }
}
function setPalette(n) {
    (0, palettes_1.paletteSet)(n);
    syncLuts();
    ui.setPaletteChips(n);
    renderer.pal = palettes_1.palette;
    renderer.recolor();
    ui.poke();
}
function setJulia(cre, cim) {
    (0, state_1.pushHistory)();
    state_1.state.mode = 'julia';
    state_1.state.cRe = cre;
    state_1.state.cIm = cim;
    core_1.view.re = cre;
    core_1.view.im = cim;
    core_1.view.unitsPerPixel = (0, interact_1.clampZoomUpp)((config_1.CFG.juliaSpan / Math.min(core_1.view.width, core_1.view.height)));
    ui.showPreview(false);
    ui.setMode('julia', cre, cim);
    ui.toast(`Julia set for c = ${cre.toFixed(6)} ${cim >= 0 ? '+' : '−'} ${Math.abs(cim).toFixed(6)}  (press J to exit)`);
    markDirty();
}
function exitJulia() {
    (0, state_1.pushHistory)();
    state_1.state.mode = 'mandelbrot';
    ui.showPreview(true);
    ui.setMode('mandelbrot', 0, 0);
    ui.toast('Mandelbrot view');
    markDirty();
}
function toggleJulia() {
    if (state_1.state.mode === 'mandelbrot') {
        const [cre, cim] = [core_1.view.re, core_1.view.im];
        setJulia(cre, cim);
    }
    else {
        exitJulia();
    }
}
function applyViewState(v) {
    state_1.state.mode = v.mode;
    state_1.state.cRe = v.cRe;
    state_1.state.cIm = v.cIm;
    state_1.state.iterOverride = v.maxIter !== autoIterSnapshotFor(v) ? v.maxIter : state_1.state.iterOverride;
    // Restore exact numbers (bitwise) — this is what makes bookmark round-trips identical.
    core_1.view.re = v.re;
    core_1.view.im = v.im;
    core_1.view.unitsPerPixel = v.upp;
    (0, palettes_1.paletteSet)(v.pal);
    (0, palettes_1.paletteSetOffset)(v.offset);
    syncLuts();
    ui.setPaletteChips(v.pal);
    ui.showPreview(v.mode === 'mandelbrot');
    ui.setMode(v.mode, v.cRe, v.cIm);
    syncItersUI(v.maxIter);
    markDirty();
}
function autoIterSnapshotFor(v) {
    // the auto value for the view we are about to restore (used to detect manual override)
    return Math.round(config_1.CFG.iterBase + config_1.CFG.iterDepthScale * Math.log10(Math.max(1, 1 / v.upp)));
}
// ---------------- actions (implement interact.Actions) ----------------
function setDragMode(m) {
    state_1.state.dragMode = m;
    const btn = document.getElementById('btnDrag');
    if (btn) {
        btn.textContent = `Drag: ${m === 'zoom' ? 'ZoomBox' : 'Pan'}`;
        btn.classList.toggle('active', m === 'pan');
    }
}
const actions = {
    panTo(re, im) {
        core_1.view.re = re;
        core_1.view.im = im;
        checkPrecision();
        markDirty();
    },
    beginPan() {
        (0, state_1.pushHistory)();
    },
    zoomAt(sx, sy, factor) {
        (0, interact_1.zoomAtPoint)(sx, sy, factor);
        checkPrecision();
        markDirty();
    },
    zoomToRect(x0, y0, x1, y1) {
        (0, state_1.pushHistory)();
        (0, interact_1.zoomToRect)(x0, y0, x1, y1);
        checkPrecision();
        markDirty();
    },
    setJulia(re, im) {
        setJulia(re, im);
    },
    toggleJulia() {
        toggleJulia();
    },
    back() {
        const h = (0, state_1.popHistory)();
        if (!h) {
            ui.toast('Nothing in the zoom history');
            return;
        }
        // restore mode + view from the entry (palette/iters included)
        state_1.state.mode = h.mode;
        state_1.state.cRe = h.cRe;
        state_1.state.cIm = h.cIm;
        core_1.view.re = h.re;
        core_1.view.im = h.im;
        core_1.view.unitsPerPixel = h.upp;
        (0, palettes_1.paletteSet)(h.pal);
        (0, palettes_1.paletteSetOffset)(h.offset);
        syncLuts();
        ui.setPaletteChips(h.pal);
        ui.showPreview(h.mode === 'mandelbrot');
        ui.setMode(h.mode, h.cRe, h.cIm);
        syncItersUI(h.maxIter);
        ui.toast('Back');
        markDirty();
    },
    reset() {
        (0, state_1.pushHistory)();
        state_1.state.mode = 'mandelbrot';
        state_1.state.cRe = -0.75;
        state_1.state.cIm = 0.1;
        core_1.view.re = -0.5;
        core_1.view.im = 0;
        core_1.view.unitsPerPixel = config_1.CFG.baseUnitsPerPixel;
        state_1.state.iterOverride = null;
        (0, palettes_1.paletteSetOffset)(palettes_1.palette.offset);
        ui.showPreview(true);
        ui.setMode('mandelbrot', 0, 0);
        syncItersUI((0, state_1.currentMaxIter)());
        ui.toast('Reset');
        markDirty();
    },
    setDragMode(m) {
        setDragMode(m);
    },
    previewJulia(cre, cim) {
        hoverC[0] = cre;
        hoverC[1] = cim;
    },
    toggleBookmarks() {
        ui.toggleBookmarks();
        ui.poke();
    },
    toggleHelp() {
        ui.toggleHelp();
    },
    escape() {
        if (ui.helpOpen()) {
            ui.toggleHelp();
        }
        else if (ui.bookmarkOpen()) {
            ui.toggleBookmarks();
        }
        else if (state_1.state.mode === 'julia') {
            exitJulia();
        }
    },
    setPalette(n) {
        setPalette(n);
    },
    adjustIters(delta) {
        state_1.state.iterOverride = Math.min(config_1.CFG.iterHardCap, Math.max(config_1.CFG.iterMinMax, (0, state_1.currentMaxIter)() + delta));
        syncItersUI(state_1.state.iterOverride);
        markDirty();
    },
    note() {
        ui.poke();
    },
    toast(msg) {
        ui.toast(msg);
    },
};
(0, interact_1.attachInteractions)(canvas, zoomRect, actions);
// ---------------- dock wiring ----------------
{
    const $id = (i) => document.getElementById(i);
    $id('btnBack').addEventListener('click', () => actions.back());
    $id('btnReset').addEventListener('click', () => actions.reset());
    $id('btnDrag').addEventListener('click', () => setDragMode(state_1.state.dragMode === 'zoom' ? 'pan' : 'zoom'));
    $id('btnJulia').addEventListener('click', () => toggleJulia());
    $id('btnBookmarks').addEventListener('click', () => actions.toggleBookmarks());
    $id('btnHelp').addEventListener('click', () => actions.toggleHelp());
    const offRange = $id('offsetRange');
    const itRange = $id('itersRange');
    const itAuto = $id('itersAuto');
    state_1.state.drifting = false;
    offRange.value = String(Math.round(palettes_1.palette.offset * 1000));
    offRange.addEventListener('input', () => {
        (0, palettes_1.paletteSetOffset)((+offRange.value) / 1000);
        renderer.pal = palettes_1.palette;
        renderer.recolor();
        ui.poke();
    });
    const setRange = (v) => { itRange.value = String(Math.round((v / config_1.CFG.iterHardCap) * 1000)); };
    setRange((0, state_1.currentMaxIter)());
    itRange.addEventListener('input', () => {
        const v = Math.round((+itRange.value / 1000) * config_1.CFG.iterHardCap);
        state_1.state.iterOverride = Math.max(10, Math.min(config_1.CFG.iterHardCap, v));
        itAuto.checked = false;
        markDirty();
        ui.poke();
    });
    itAuto.addEventListener('change', () => {
        state_1.state.iterOverride = itAuto.checked ? null : (0, state_1.currentMaxIter)();
        if (!itAuto.checked)
            setRange((0, state_1.currentMaxIter)());
        markDirty();
    });
    syncItersUI = (v) => {
        itAuto.checked = state_1.state.iterOverride === null;
        setRange(v);
    };
    syncItersUI((0, state_1.currentMaxIter)());
    // bookmarks
    const bmName = $id('bmName');
    $id('bmSave').addEventListener('click', () => {
        ui.addBookmark(bmName.value.trim(), (0, state_1.captureView)(palettes_1.palette.name, palettes_1.palette.offset, (0, state_1.currentMaxIter)()));
        bmName.value = '';
        ui.toast('Bookmark saved');
    });
    bmName.addEventListener('keydown', (e) => {
        if (e.key === 'Enter')
            $id('bmSave').click();
    });
}
// ---------------- hash state (shareable link) ----------------
let hashTimer = null;
function encodeHash() {
    const v = (0, state_1.captureView)(palettes_1.palette.name, palettes_1.palette.offset, (0, state_1.currentMaxIter)());
    const p = [
        state_1.state.mode === 'julia' ? 1 : 0,
        v.re, v.im, v.upp,
        state_1.state.mode === 'julia' ? v.cRe : 0,
        state_1.state.mode === 'julia' ? v.cIm : 0,
        v.pal, v.offset, v.maxIter,
    ];
    return p.join(',');
}
function writeHash() {
    if (hashTimer)
        window.clearTimeout(hashTimer);
    hashTimer = window.setTimeout(() => {
        try {
            window.history.replaceState(null, '', '#' + encodeHash());
        }
        catch { /* file:// */ }
    }, 300);
}
function readHash() {
    const h = location.hash.slice(1);
    if (!h)
        return null;
    try {
        const parts = h.split(',');
        if (parts.length < 9)
            return null;
        const mode = parts[0] === '1' ? 'julia' : 'mandelbrot';
        return {
            re: +parts[1], im: +parts[2], upp: +parts[3],
            mode, cRe: +parts[4], cIm: +parts[5],
            pal: ['fire', 'ocean', 'mono', 'psy'].includes(parts[6]) ? parts[6] : 'fire',
            offset: +parts[7], maxIter: +parts[8],
        };
    }
    catch {
        return null;
    }
}
// ---------------- hover preview c ----------------
const hoverC = [state_1.state.cRe, state_1.state.cIm];
let lastPreviewAt = 0;
let lastPreviewC = [0, 0];
// ---------------- main loop ----------------
let lastT = performance.now();
let fpsEma = 0;
let lastHudAt = 0;
let lastColorOffset = palettes_1.palette.offset;
let prevIdle = false;
function loop(t) {
    const dt = Math.min(100, t - lastT);
    lastT = t;
    const fps = dt > 0 ? 1000 / dt : 60;
    fpsEma = fpsEma === 0 ? fps : fpsEma * 0.9 + fps * 0.1;
    // palette drift (colorize-only — no iteration recompute)
    if (state_1.state.drifting || driftForced) {
        (0, palettes_1.paletteSetOffset)(palettes_1.palette.offset + (dt / 1000) * config_1.CFG.driftSpeed);
        const offRange = document.getElementById('offsetRange');
        if (offRange && !offRange.matches(':active')) {
            offRange.value = String(Math.round(palettes_1.palette.offset * 1000));
        }
        renderer.pal = palettes_1.palette;
        renderer.recolor();
        if (t - lastPreviewAt > config_1.CFG.previewRefreshMs) {
            lastPreviewAt = t;
            if (state_1.state.mode === 'mandelbrot' && !ui.previewHidden()) {
                ui.renderPreview(hoverC[0], hoverC[1], palettes_1.palette.name, palettes_1.palette.offset, config_1.CFG.previewIters);
            }
        }
    }
    // live Julia preview on hover (throttled)
    if (state_1.state.mode === 'mandelbrot' && t - lastPreviewAt > config_1.CFG.previewRefreshMs) {
        lastPreviewAt = t;
        if (hoverC[0] !== lastPreviewC[0] || hoverC[1] !== lastPreviewC[1]) {
            lastPreviewC = [hoverC[0], hoverC[1]];
            ui.renderPreview(hoverC[0], hoverC[1], palettes_1.palette.name, palettes_1.palette.offset, config_1.CFG.previewIters);
        }
    }
    // render
    renderer.onRenderComplete = (seq) => {
        writeHash();
        void seq;
    };
    renderer.tick();
    // If the palette offset changed while a render was in flight (drift or slider),
    // apply it the moment the render settles — otherwise a mid-render view would
    // freeze at the pre-change color scheme.
    if (prevIdle && !renderer.isIdle())
        lastColorOffset = palettes_1.palette.offset;
    if (!prevIdle && renderer.isIdle() && palettes_1.palette.offset !== lastColorOffset) {
        lastColorOffset = palettes_1.palette.offset;
        renderer.recolor();
    }
    prevIdle = renderer.isIdle();
    // HUD ~4x/sec
    if (t - lastHudAt > 250) {
        lastHudAt = t;
        ui.update(fpsEma);
    }
    requestAnimationFrame(loop);
}
// ---------------- keyboard extras (drift toggle via D) ----------------
window.addEventListener('keydown', (e) => {
    const tag = e.target?.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA')
        return;
    if (e.key === 'd' || e.key === 'D') {
        driftForced = !driftForced;
        ui.toast(driftForced ? 'Color drift: ON' : 'Color drift: OFF');
    }
});
// ---------------- start ----------------
state_1.state.drifting = false; // drift is opt-in via D (default off keeps palettes stable)
setDragMode('zoom');
ui.showPreview(true);
ui.setMode('mandelbrot', 0, 0);
const fromHash = readHash();
if (fromHash) {
    applyViewState(fromHash);
    ui.toast('View restored from link');
}
markDirty(); // initial render (pal is set here)
requestAnimationFrame(loop);
window.fractal = {
    /** Escape iterations at a point. mode defaults to current; interior -> -1. */
    iterationsAt(re, im, opts) {
        const mode = opts?.mode ?? state_1.state.mode;
        const c = opts?.c ?? [state_1.state.cRe, state_1.state.cIm];
        const mi = opts?.maxIter ?? (0, state_1.currentMaxIter)();
        return (0, core_1.iterationsAt)(re, im, mode, c[0], c[1], mi);
    },
    /** Jump to an exact view. Accepts {re, im, mag, scale, mode, c} —
     *  mag = absolute magnification, scale = relative zoom factor. */
    setView(v) {
        if (v.mode) {
            state_1.state.mode = v.mode;
            if (v.c) {
                state_1.state.cRe = v.c[0];
                state_1.state.cIm = v.c[1];
            }
            ui.setMode(state_1.state.mode, state_1.state.cRe, state_1.state.cIm);
            ui.showPreview(state_1.state.mode === 'mandelbrot');
        }
        if (v.re !== undefined)
            core_1.view.re = v.re;
        if (v.im !== undefined)
            core_1.view.im = v.im;
        if (v.mag !== undefined) {
            // absolute magnification (pixels per unit = 1/mag)
            core_1.view.unitsPerPixel = (0, interact_1.clampZoomUpp)(config_1.CFG.baseUnitsPerPixel / v.mag);
        }
        else if (v.scale !== undefined) {
            // scale is a zoom factor RELATIVE to the current magnification (1.0 = no change)
            core_1.view.unitsPerPixel = (0, interact_1.clampZoomUpp)(core_1.view.unitsPerPixel / v.scale);
        }
        markDirty();
    },
    get state() {
        return (0, state_1.captureView)(palettes_1.palette.name, palettes_1.palette.offset, (0, state_1.currentMaxIter)());
    },
    /** Save/load bookmarks. */
    bookmarks: {
        list: () => bookmarkSnapshot(),
        save: (name) => {
            ui.addBookmark(name, (0, state_1.captureView)(palettes_1.palette.name, palettes_1.palette.offset, (0, state_1.currentMaxIter)()));
            return name;
        },
        restore: (name) => {
            const bm = bookmarkSnapshot().find((b) => b.name === name);
            if (!bm)
                return false;
            applyViewState({
                re: bm.re, im: bm.im, upp: bm.upp, mode: bm.mode,
                cRe: bm.cRe, cIm: bm.cIm, pal: bm.pal, offset: bm.offset, maxIter: bm.maxIter,
            });
            return true;
        },
    },
    /** Simulate a drag-zoom into a rect (CSS px). Mirrors the UI exactly. */
    zoomRect(x0, y0, x1, y1) {
        (0, state_1.pushHistory)();
        (0, interact_1.zoomToRect)(x0, y0, x1, y1);
        checkPrecision();
        markDirty();
        return { re: core_1.view.re, im: core_1.view.im, mag: 1 / core_1.view.unitsPerPixel };
    },
    /** Pan by screen pixels (world delta = -dx*upp, +dy*upp). */
    pan(dx, dy) {
        (0, state_1.pushHistory)();
        core_1.view.re -= dx * core_1.view.unitsPerPixel;
        core_1.view.im += dy * core_1.view.unitsPerPixel;
        markDirty();
    },
    /** True once the current render (coarse + full) has finished. */
    isIdle() {
        return renderer.isIdle();
    },
    /** Promise that resolves (true) when rendering is idle, or false on timeout. */
    waitIdle(timeoutMs = 20000) {
        return new Promise((resolve) => {
            const t0 = performance.now();
            const check = () => {
                if (renderer.isIdle()) {
                    resolve(true);
                }
                else if (performance.now() - t0 > timeoutMs) {
                    resolve(false);
                }
                else {
                    requestAnimationFrame(check);
                }
            };
            check();
        });
    },
    /** Sample the displayed canvas: mean/std of RGB + distinct-color count (structure check). */
    sampleCanvas(w = 240, h = 135) {
        const cv = document.createElement('canvas');
        cv.width = w;
        cv.height = h;
        const c2 = cv.getContext('2d');
        c2.drawImage(canvas, 0, 0, w, h);
        const d = c2.getImageData(0, 0, w, h).data;
        const seen = new Set();
        let sr = 0, sg = 0, sb = 0;
        for (let i = 0; i < d.length; i += 4) {
            const r = d[i], g = d[i + 1], b = d[i + 2];
            sr += r;
            sg += g;
            sb += b;
            seen.add((r << 16) | (g << 8) | b);
        }
        const n = d.length / 4;
        let vr = 0, vg = 0, vb = 0;
        const mr = sr / n, mg = sg / n, mb = sb / n;
        for (let i = 0; i < d.length; i += 4) {
            vr += (d[i] - mr) * (d[i] - mr);
            vg += (d[i + 1] - mg) * (d[i + 1] - mg);
            vb += (d[i + 2] - mb) * (d[i + 2] - mb);
        }
        return { mean: [mr, mg, mb], std: [Math.sqrt(vr / n), Math.sqrt(vg / n), Math.sqrt(vb / n)], distinct: seen.size };
    },
    /** Escape values in the rendered field buffer at screen coords (CSS px), for debugging. */
    fieldAt(points) {
        // reach into the renderer's full frame via a debug hook
        return renderer.debugFieldAt
            ? renderer.debugFieldAt(points)
            : [];
    },
    rendererInfo() {
        return renderer.info
            ? renderer.info()
            : {};
    },
    paletteName() {
        return palettes_1.palette.name;
    },
    paletteLutAt(i) {
        const w = palettes_1.palette.lut[i];
        return [w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff];
    },
    /** Escape-time value + color for a point (smooth). */
    probe(re, im) {
        const v = (0, core_1.iterationsAt)(re, im, state_1.state.mode, state_1.state.cRe, state_1.state.cIm, (0, state_1.currentMaxIter)());
        return { v, rgb: v < 0 ? [0, 0, 0] : colorProbe(v, (0, state_1.currentMaxIter)(), palettes_1.palette.offset) };
    },
};
function bookmarkSnapshot() {
    try {
        return JSON.parse(localStorage.getItem(config_1.CFG.bookmarkKey) || '[]');
    }
    catch {
        return [];
    }
}
// small local color probe (mirrors palettes.colorize math)
function colorProbe(v, maxIter, offset) {
    const L = 2048;
    const t = Math.sqrt(Math.min(1, v / maxIter)) * L + offset * L;
    let ft = t;
    if (ft < 0)
        ft += L;
    const w = palettes_1.palette.lut[(ft | 0) % L];
    return [w & 0xff, (w >> 8) & 0xff, (w >> 16) & 0xff];
}
};

// ── module: src/config.ts ──
__mods["src/config.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// TUNABLE CONSTANTS — edit these to tune behavior
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.PALETTES = exports.CFG = void 0;
exports.CFG = {
    // Base resolution: pixels per unit at magnification 1.0 (default view spans ~3 units).
    baseUnitsPerPixel: 3 / 1600,
    // Iteration limit: base + depth * log10(mag), clamped to [minMax, hardCap].
    iterBase: 160,
    iterDepthScale: 110,
    iterMinMax: 120,
    iterHardCap: 90000,
    // Wheel zoom: 1 "notch" = wheelPixelsPerNotch units of |deltaY| (a typical
    // mouse scroll step is 100). factor = wheelZoomPow^notches applied to upp
    // (positive delta = scroll down = zoom out).
    wheelZoomPow: 1.4,
    wheelPixelsPerNotch: 100,
    keyboardZoomPow: 1.5,
    keyboardPanFrac: 0.12, // fraction of view width per arrow press
    // Max magnification cap: double precision degrades visibly around 1e13.
    maxMagnification: 1e13,
    // Warn once when crossing this fraction of the cap.
    warnMagnitude: 5e11,
    // Drag-zoom: min pixel drag (both axes) to count as a zoom box, else it's a click.
    dragMinPx: 7,
    // A click under this duration-and-drag opens the Julia set.
    clickMaxPx: 4,
    // Progressive refinement passes: pixel stride (1 = full resolution).
    passes: [4, 2, 1],
    // Cap on ms spent per main-render frame before yielding to the event loop.
    frameBudgetMs: 12,
    // Minimum ms between coarse passes (keeps interactions snappy on small zooms).
    minPassIntervalMs: 34,
    // Idle UI hide delay.
    idleHideMs: 2600,
    // Palette cycling (drift).
    driftSpeed: 0.05, // palette-offset units per second while drifting
    offsetScale: 1, // divides raw offset range into the 0..1 palette domain
    // LUT size for palette colorization.
    lutSize: 2048,
    // Julia preview pane.
    previewSize: 144, // CSS pixels
    previewIters: 90,
    previewRefreshMs: 70,
    // Julia mode view span (units across the smaller side) centered on c.
    juliaSpan: 3,
    // History stack depth.
    historyCap: 60,
    // localStorage keys.
    bookmarkKey: 'fractalExplorer.bookmarks.v1',
};
exports.PALETTES = {
    fire: {
        cyclic: false,
        swatch: 'linear-gradient(90deg,#000004,#3b0614,#a81a0a,#ff7b00,#ffd54a,#fff8e6)',
        stops: [
            [0.0, 0, 0, 4],
            [0.18, 59, 6, 20],
            [0.42, 168, 26, 10],
            [0.62, 255, 123, 0],
            [0.82, 255, 213, 74],
            [1.0, 255, 248, 230],
        ],
    },
    ocean: {
        cyclic: false,
        swatch: 'linear-gradient(90deg,#020208,#04122e,#0a3a66,#1e6f8f,#43c6ac,#d8f7e8)',
        stops: [
            [0.0, 2, 2, 8],
            [0.25, 4, 18, 46],
            [0.5, 10, 58, 102],
            [0.72, 30, 111, 143],
            [0.88, 67, 198, 172],
            [1.0, 216, 247, 232],
        ],
    },
    mono: {
        cyclic: false,
        swatch: 'linear-gradient(90deg,#000000,#3a3f4a,#7d8496,#c3cad8,#ffffff)',
        stops: [
            [0.0, 0, 0, 0],
            [0.35, 58, 63, 74],
            [0.65, 125, 132, 150],
            [0.85, 195, 202, 216],
            [1.0, 255, 255, 255],
        ],
    },
    psy: {
        cyclic: true,
        swatch: 'linear-gradient(90deg,#ff2d55,#ffcc00,#2dff88,#00cfff,#a64dff,#ff2d55)',
        stops: [
            [0.0, 255, 45, 85],
            [0.17, 255, 204, 0],
            [0.33, 45, 255, 136],
            [0.5, 0, 207, 255],
            [0.67, 166, 77, 255],
            [0.83, 255, 61, 165],
            [1.0, 255, 45, 85],
        ],
    },
};
};

// ── module: src/palettes.ts ──
__mods["src/palettes.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// PALETTE ENGINE — stops -> LUT, escape value -> color
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.palette = void 0;
exports.buildLUT = buildLUT;
exports.paletteSet = paletteSet;
exports.paletteSetOffset = paletteSetOffset;
exports.colorize = colorize;
exports.colorForValue = colorForValue;
const config_1 = require("./config");
const LUT = config_1.CFG.lutSize;
/** Build a 2048-entry little-endian ABGR LUT from a palette's stops. */
function buildLUT(name) {
    const stops = config_1.PALETTES[name].stops;
    const lut = new Uint32Array(LUT);
    // walk stops, lerp rgb
    for (let i = 0; i < LUT; i++) {
        const t = i / (LUT - 1);
        // find segment
        let s = 0;
        while (s < stops.length - 2 && t > stops[s + 1][0])
            s++;
        const a = stops[s];
        const b = stops[s + 1];
        const span = b[0] - a[0];
        const f = span > 0 ? (t - a[0]) / span : 0;
        const r = a[1] + (b[1] - a[1]) * f;
        const g = a[2] + (b[2] - a[2]) * f;
        const bl = a[3] + (b[3] - a[3]) * f;
        const ri = Math.min(255, Math.max(0, Math.round(r)));
        const gi = Math.min(255, Math.max(0, Math.round(g)));
        const bi = Math.min(255, Math.max(0, Math.round(bl)));
        // ImageData bytes are RGBA (little-endian): pack R in the low byte.
        lut[i] = (0xff << 24) | (bi << 16) | (gi << 8) | ri;
    }
    return lut;
}
exports.palette = {
    name: 'fire',
    offset: 0.35,
    lut: new Uint32Array(LUT),
};
function paletteSet(name) {
    exports.palette.name = name;
    exports.palette.lut = buildLUT(name);
}
function paletteSetOffset(o) {
    exports.palette.offset = o - Math.floor(o); // wrap into 0..1
    // No iteration recompute: the next colorize pass picks this up.
}
/**
 * Colorize an iteration buffer into a Uint32 pixel buffer (in place per index).
 * v < 0  -> interior (black)
 * v >= 0 -> smooth t; normalized by maxIter, sqrt-spread, then LUT lookup
 * with a cyclic drift offset.
 */
function colorize(buf, maxIter, out, pal, drift) {
    const lut = pal.lut;
    const n = buf.length;
    const norm = maxIter > 0 ? 1 / maxIter : 0;
    const d = drift * LUT;
    for (let i = 0; i < n; i++) {
        const v = buf[i];
        if (v < 0) {
            out[i] = 0xff000000;
            continue;
        }
        // sqrt-spread: pushes the outer (slow-escaping) region dark, brightens near the set
        const t = Math.sqrt(v * norm);
        let f = t * LUT + d;
        // clamp the gradient part to [0, LUT), add drift cyclically
        if (f < 0)
            f += LUT;
        const idx = (f | 0) % LUT;
        out[i] = lut[idx];
    }
}
/** Debug helper: RGB for a single smooth value (used by the headless probe). */
function colorForValue(v, maxIter, drift) {
    if (v < 0)
        return [0, 0, 0];
    const t = Math.sqrt(Math.max(0, v) / Math.max(1, maxIter));
    let f = t * LUT + drift * LUT;
    if (f < 0)
        f += LUT;
    const u = (f | 0) % LUT;
    const word = exports.palette.lut[u];
    // bytes are [R, G, B, A] in the low-to-high order
    return [word & 0xff, (word >> 8) & 0xff, (word >> 16) & 0xff];
}
};

// ── module: src/core.ts ──
__mods["src/core.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// FRACTAL MATH — view, escape iterations, smooth coloring
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.view = void 0;
exports.mag = mag;
exports.setView = setView;
exports.clampMagnification = clampMagnification;
exports.autoIterations = autoIterations;
exports.computeRows = computeRows;
exports.computeAll = computeAll;
exports.mandelEscape = mandelEscape;
exports.juliaEscape = juliaEscape;
exports.iterationsAt = iterationsAt;
exports.screenToComplex = screenToComplex;
exports.complexToScreen = complexToScreen;
const config_1 = require("./config");
exports.view = {
    re: -0.5,
    im: 0,
    unitsPerPixel: config_1.CFG.baseUnitsPerPixel,
    width: 1,
    height: 1,
};
function mag() {
    return 1 / exports.view.unitsPerPixel;
}
function setView(re, im, m, width, height) {
    exports.view.re = re;
    exports.view.im = im;
    exports.view.unitsPerPixel = config_1.CFG.baseUnitsPerPixel / clampMagnification(m);
    exports.view.width = width;
    exports.view.height = height;
}
function clampMagnification(m) {
    return Math.min(config_1.CFG.maxMagnification, Math.max(0.05, m));
}
/** Auto iteration limit: scales with zoom depth. */
function autoIterations(m) {
    const mm = clampMagnification(m);
    const est = config_1.CFG.iterBase + config_1.CFG.iterDepthScale * Math.log10(Math.max(1, mm));
    return Math.round(Math.min(config_1.CFG.iterHardCap, Math.max(config_1.CFG.iterMinMax, est)));
}
// Pixel (px, py) in screen space maps to:
//   re = view.re + (px - view.width/2) * upp
//   im = view.im - (py - view.height/2) * upp   (screen y grows downward)
// Both variants share this row-wise kernel; `dpr` scales the sampling grid
// (pass dpr/4 for the 1/4-resolution coarse preview of the same view).
function computeRowsInto(buf, w, startPy, endPy, upp, x0, yTop, maxIter, mode, cRe, cIm, deadline) {
    const isMandel = mode === 'mandelbrot';
    let rowsDone = 0;
    for (let py = startPy; py < endPy; py++) {
        if (py > startPy && performance.now() > deadline)
            break;
        const im0 = yTop - py * upp;
        let i = py * w;
        for (let px = 0; px < w; px++) {
            const re0 = x0 + px * upp;
            buf[i++] = isMandel ? mandelEscape(re0, im0, maxIter) : juliaEscape(re0, im0, cRe, cIm, maxIter);
        }
        rowsDone++;
    }
    return rowsDone;
}
/**
 * Compute full-density rows [startPy, startPy+rowCap) of field.buf, bounded by
 * a time budget. Returns the number of rows actually completed (0 on timeout).
 */
function computeRows(field, startPy, rowCap, mode, cRe, cIm, dpr, maxMs) {
    const w = Math.max(1, Math.round(exports.view.width * dpr));
    const upp = exports.view.unitsPerPixel * dpr;
    const x0 = exports.view.re - ((exports.view.width * dpr) / 2) * upp;
    const yTop = exports.view.im + ((exports.view.height * dpr) / 2) * upp;
    const h = Math.max(1, Math.round(exports.view.height * dpr));
    const endPy = Math.min(h, startPy + rowCap);
    if (startPy >= h)
        return 0;
    return computeRowsInto(field.buf, w, startPy, endPy, upp, x0, yTop, field.maxIter, mode, cRe, cIm, performance.now() + maxMs);
}
/** Compute field buffer rows from `startRow` onward (used for the 1/4-res coarse
 *  canvas). Returns the number of rows completed (0 on immediate timeout). */
function computeAll(field, mode, cRe, cIm, dpr, startRow, maxMs) {
    const w = Math.max(1, Math.round(exports.view.width * dpr));
    const h = Math.max(1, Math.round(exports.view.height * dpr));
    const upp = exports.view.unitsPerPixel * dpr;
    const x0 = exports.view.re - ((exports.view.width * dpr) / 2) * upp;
    const yTop = exports.view.im + ((exports.view.height * dpr) / 2) * upp;
    if (startRow >= h)
        return 0;
    return computeRowsInto(field.buf, w, startRow, h, upp, x0, yTop, field.maxIter, mode, cRe, cIm, performance.now() + maxMs);
}
/** Mandelbrot: c = (re, im), z starts at 0. */
function mandelEscape(re, im, maxIter) {
    let zr = 0, zi = 0;
    let cr = re, ci = im;
    for (let n = 0; n < maxIter; n++) {
        // z = z*z + c
        const t = 2 * zr * zi;
        zr = zr * zr - zi * zi + cr;
        zi = t + ci;
        if (zr * zr + zi * zi > 256) {
            // smooth: nu = n + 1 - log2(log|z|)  (|z|^2 used to avoid sqrt)
            const logZ = Math.log(zr * zr + zi * zi) / 2;
            const nu = Math.log(logZ) / Math.LN2;
            return n + 1 - nu;
        }
    }
    return -1;
}
/** Julia: c fixed, z starts at (re, im). */
function juliaEscape(re, im, cr, ci, maxIter) {
    let zr = re, zi = im;
    for (let n = 0; n < maxIter; n++) {
        const t = 2 * zr * zi;
        zr = zr * zr - zi * zi + cr;
        zi = t + ci;
        if (zr * zr + zi * zi > 256) {
            const logZ = Math.log(zr * zr + zi * zi) / 2;
            const nu = Math.log(logZ) / Math.LN2;
            return n + 1 - nu;
        }
    }
    return -1;
}
/** Probe helper: iterations at a point (negative = interior). */
function iterationsAt(re, im, mode, cRe, cIm, maxIter) {
    return mode === 'mandelbrot' ? mandelEscape(re, im, maxIter) : juliaEscape(re, im, cRe, cIm, maxIter);
}
// ------------------------------------------------------------
// View transforms (screen <-> complex), aspect-correct.
// ------------------------------------------------------------
function screenToComplex(sx, sy) {
    return [
        exports.view.re + (sx - exports.view.width / 2) * exports.view.unitsPerPixel,
        exports.view.im - (sy - exports.view.height / 2) * exports.view.unitsPerPixel,
    ];
}
function complexToScreen(re, im) {
    return [
        exports.view.width / 2 + (re - exports.view.re) / exports.view.unitsPerPixel,
        exports.view.height / 2 - (im - exports.view.im) / exports.view.unitsPerPixel,
    ];
}
};

// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// RENDERER — progressive ImageData pipeline with cancellation
// ============================================================
// Coarse: render the view at 1/4 density into a small canvas and upscale
// (fast feedback, top-down). Fine: full resolution in horizontal bands,
// colorized and blitted as they complete. Any view change invalidates the
// in-flight pass; the next frame restarts cleanly — no ghost tiles.
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const config_1 = require("./config");
const core_1 = require("./core");
const palettes_1 = require("./palettes");
const COARSE_DPR = 0.25;
const BAND_ROWS = 64; // full-res rows per blit band
class Renderer {
    constructor() {
        this.full = null;
        this.coarse = null;
        this.seq = 0; // invalidation counter
        this.activeSeq = -1; // seq currently being rendered
        this.row = 0; // next full-res row to compute
        this.coarseRow = 0; // next coarse row to compute
        this.coarseDoneAt = -1; // seq at which the coarse frame finished
        this.coarseSeq = -1; // seq of the displayed coarse frame
        this.pendingWork = false;
        this.skipCoarse = false; // this seq is satisfied without a fresh coarse pass
        this.driftSnap = 0; // palette offset frozen at seq start (no color seams across bands)
        this.mode = 'mandelbrot';
        this.cRe = -0.75;
        this.cIm = 0;
        this.maxIter = 160;
        this.pal = null;
        this.onRenderComplete = null;
    }
    /** @deprecated palette offset is read live from `pal.offset` */
    get drift() {
        return this.pal ? this.pal.offset : 0;
    }
    /** True while a coarse or full-resolution pass is still in flight. */
    isIdle() {
        return !this.pendingWork;
    }
    /** (Re)size buffers for new CSS dimensions. */
    ensureSize(cssW, cssH, dpr, canvas) {
        const w = Math.max(1, Math.round(cssW * dpr));
        const h = Math.max(1, Math.round(cssH * dpr));
        const same = !!this.full && this.full.w === w && this.full.h === h && this.full.canvas === canvas;
        if (!same) {
            canvas.width = w;
            canvas.height = h;
            const ctx = canvas.getContext('2d', { alpha: false, willReadFrequently: true });
            if (!ctx)
                throw new Error('canvas 2d unavailable');
            const img = ctx.createImageData(w, h);
            this.full = {
                canvas, ctx,
                field: { buf: new Float32Array(w * h), maxIter: this.maxIter },
                img, img32: new Uint32Array(img.data.buffer),
                w, h, dpr,
            };
            this.coarse = null;
            this.seq++;
            this.pendingWork = true;
            this.skipCoarse = false;
            this.activeSeq = -1;
            this.row = 0;
            this.coarseRow = 0;
            this.coarseDoneAt = -1;
            this.coarseSeq = -1;
        }
        else {
            this.full.dpr = dpr;
        }
    }
    /** Build (or resize) the 1/4-resolution coarse canvas. */
    ensureCoarse() {
        const f = this.full;
        const cw = Math.max(1, Math.round(f.w * COARSE_DPR));
        const ch = Math.max(1, Math.round(f.h * COARSE_DPR));
        if (this.coarse && this.coarse.w === cw && this.coarse.h === ch) {
            return this.coarse;
        }
        const canvas = document.createElement('canvas');
        canvas.width = cw;
        canvas.height = ch;
        const ctx = canvas.getContext('2d', { alpha: false });
        const img = ctx.createImageData(cw, ch);
        this.coarse = {
            canvas, ctx,
            field: { buf: new Float32Array(cw * ch), maxIter: this.maxIter },
            img, img32: new Uint32Array(img.data.buffer),
            w: cw, h: ch,
        };
        return this.coarse;
    }
    /** Invalidate the in-flight render.
     *  'coarse' — big view change: fresh 1/4-res pass, then refine.
     *  'fine'   — small change (pan, small wheel zoom): skip the coarse pass and
     *             recompute full-resolution bands over the existing buffer. */
    invalidate(kind) {
        this.seq++;
        this.pendingWork = true;
        this.driftSnap = this.pal ? this.pal.offset : 0;
        // 'fine' may skip a fresh coarse pass ONLY when a coarse canvas of the
        // current size already exists (the full-res pass repaints everything).
        if (kind === 'fine' && this.coarse !== null) {
            this.activeSeq = this.seq; // treat coarse as already satisfied
            this.row = 0;
            this.skipCoarse = true;
            this.coarseRow = this.coarse.h; // mark coarse skipped for this seq
        }
        else {
            this.activeSeq = -1;
            this.row = 0;
            this.coarseRow = 0;
            this.coarseDoneAt = -1;
            this.skipCoarse = false;
        }
    }
    /** Colorize-only redraw of the current buffers (drift animation).
     *  Skipped mid-render: the buffer is only partially valid, and blitting it
     *  would flash the previous view's colors over the in-flight pass. */
    recolor() {
        const f = this.full;
        if (!f || !this.pal || this.pendingWork)
            return;
        (0, palettes_1.colorize)(f.field.buf, f.field.maxIter, f.img32, this.pal, this.drift);
        f.ctx.putImageData(f.img, 0, 0);
        const c = this.coarse;
        if (c && this.coarseSeq === this.activeSeq && this.coarseDoneAt === this.seq) {
            (0, palettes_1.colorize)(c.field.buf, c.field.maxIter, c.img32, this.pal, this.drift);
            c.ctx.putImageData(c.img, 0, 0);
            f.ctx.imageSmoothingEnabled = true;
            f.ctx.drawImage(c.canvas, 0, 0, f.w, f.h);
        }
    }
    /** Debug: sample the full-res field buffer at CSS-px points. */
    debugFieldAt(points) {
        const f = this.full;
        if (!f)
            return [];
        return points.map((p) => {
            const x = Math.min(f.w - 1, Math.max(0, Math.round(p[0] * f.dpr)));
            const y = Math.min(f.h - 1, Math.max(0, Math.round(p[1] * f.dpr)));
            return f.field.buf[y * f.w + x];
        });
    }
    /** Debug: internal render state. */
    info() {
        const f = this.full;
        return {
            seq: this.seq, activeSeq: this.activeSeq, row: this.row,
            coarseRow: this.coarseRow, coarseH: this.coarse ? this.coarse.h : -1,
            coarseDone: this.coarseDoneAt === this.seq, pendingWork: this.pendingWork,
            fieldMaxIter: f ? f.field.maxIter : -1, fullWH: f ? `${f.w}x${f.h}` : '-',
        };
    }
    /** Per-frame pump. Draws coarse or fine bands until the view settles. */
    tick() {
        const f = this.full;
        if (!f || !this.pal)
            return false;
        // --- Phase A: coarse preview of the current view ---
        if (this.activeSeq !== this.seq) {
            // View changed: fresh coarse pass (top-down, budgeted).
            this.activeSeq = this.seq;
            this.row = 0;
            this.coarseRow = 0;
            const c = this.ensureCoarse();
            c.field.maxIter = Math.min(this.maxIter, 400); // coarse preview cap (fine pass is exact)
            c.field.buf.fill(-1); // interior: uncomputed tail stays black, no ghosts
            this.coarseSeq = this.seq;
        }
        const c = this.coarse;
        if (c && this.coarseRow < c.h) {
            const done = (0, core_1.computeAll)(c.field, this.mode, this.cRe, this.cIm, COARSE_DPR, this.coarseRow, config_1.CFG.frameBudgetMs);
            if (done > 0) {
                this.coarseRow += done;
                (0, palettes_1.colorize)(c.field.buf, c.field.maxIter, c.img32, this.pal, this.driftSnap);
                c.ctx.putImageData(c.img, 0, 0);
                f.ctx.imageSmoothingEnabled = true;
                f.ctx.drawImage(c.canvas, 0, 0, f.w, f.h);
                if (this.coarseRow >= c.h)
                    this.coarseDoneAt = this.seq;
            }
            return true;
        }
        // --- Phase B: full-resolution bands ---
        if (this.row >= f.h) {
            if (this.coarseDoneAt === this.seq || this.skipCoarse) {
                this.coarseDoneAt = -1;
                this.skipCoarse = false;
                this.pendingWork = false;
                if (this.onRenderComplete)
                    this.onRenderComplete(this.seq);
            }
            return false;
        }
        f.field.maxIter = this.maxIter;
        const cap = Math.min(BAND_ROWS, f.h - this.row);
        const done = (0, core_1.computeRows)(f.field, this.row, cap, this.mode, this.cRe, this.cIm, f.dpr, config_1.CFG.frameBudgetMs);
        if (done > 0) {
            const buf = f.field.buf;
            const out = f.img32;
            const w = f.w;
            const maxIter = f.field.maxIter;
            const lut = this.pal.lut;
            const L = lut.length;
            const norm = 1 / maxIter;
            const d0 = this.driftSnap * L;
            for (let r = this.row; r < this.row + done; r++) {
                let i = r * w;
                const rowEnd = i + w;
                while (i < rowEnd) {
                    const v = buf[i];
                    if (v < 0) {
                        out[i] = 0xff000000;
                    }
                    else {
                        const t = Math.sqrt(v * norm) * L + d0;
                        let ft = t;
                        if (ft < 0)
                            ft += L;
                        out[i] = lut[(ft | 0) % L];
                    }
                    i++;
                }
            }
            // Blit ONLY the completed band (putImageData's dirty-rect form — a plain
            // (img, 0, row) paste would shift the whole image down).
            f.ctx.putImageData(f.img, 0, 0, 0, this.row, f.w, done);
            this.row += done;
        }
        return done > 0;
    }
}
exports.Renderer = Renderer;
};

// ── module: src/state.ts ──
__mods["src/state.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// STATE — app settings, zoom history, bookmarks (localStorage)
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.history = exports.state = void 0;
exports.currentMaxIter = currentMaxIter;
exports.pushHistory = pushHistory;
exports.popHistory = popHistory;
exports.loadBookmarks = loadBookmarks;
exports.saveBookmarks = saveBookmarks;
exports.captureView = captureView;
exports.sameView = sameView;
const config_1 = require("./config");
const core_1 = require("./core");
const palettes_1 = require("./palettes");
exports.state = {
    mode: 'mandelbrot',
    cRe: -0.75,
    cIm: 0.1,
    iterOverride: null,
    dragMode: 'zoom',
    drifting: true,
};
function currentMaxIter() {
    if (exports.state.iterOverride !== null)
        return exports.state.iterOverride;
    return (0, core_1.autoIterations)((0, core_1.mag)());
}
exports.history = [];
function pushHistory() {
    exports.history.push({
        re: core_1.view.re,
        im: core_1.view.im,
        upp: core_1.view.unitsPerPixel,
        mode: exports.state.mode,
        cRe: exports.state.cRe,
        cIm: exports.state.cIm,
        pal: palettes_1.palette.name,
        offset: palettes_1.palette.offset,
        maxIter: currentMaxIter(),
    });
    if (exports.history.length > config_1.CFG.historyCap)
        exports.history.shift();
}
function popHistory() {
    return exports.history.length ? exports.history.pop() : null;
}
function loadBookmarks() {
    try {
        const raw = localStorage.getItem(config_1.CFG.bookmarkKey);
        if (!raw)
            return [];
        const arr = JSON.parse(raw);
        return Array.isArray(arr) ? arr : [];
    }
    catch {
        return [];
    }
}
function saveBookmarks(bms) {
    try {
        localStorage.setItem(config_1.CFG.bookmarkKey, JSON.stringify(bms));
    }
    catch {
        // storage unavailable — ignore
    }
}
function captureView(pal, offset, maxIter) {
    return {
        re: core_1.view.re,
        im: core_1.view.im,
        upp: core_1.view.unitsPerPixel,
        mode: exports.state.mode,
        cRe: exports.state.cRe,
        cIm: exports.state.cIm,
        pal,
        offset,
        maxIter,
    };
}
function sameView(a, b) {
    return (a.re === b.re && a.im === b.im && a.upp === b.upp &&
        a.mode === b.mode && a.cRe === b.cRe && a.cIm === b.cIm &&
        a.pal === b.pal && a.offset === b.offset && a.maxIter === b.maxIter);
}
};

// ── module: src/interact.ts ──
__mods["src/interact.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// INTERACTION — pointer (zoom box, pan, wheel, click) + keyboard
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.pointer = exports.zoomBox = void 0;
exports.attachInteractions = attachInteractions;
exports.updateZoomRect = updateZoomRect;
exports.hideZoomRect = hideZoomRect;
exports.zoomAtPoint = zoomAtPoint;
exports.zoomToRect = zoomToRect;
exports.clampZoomUpp = clampZoomUpp;
const config_1 = require("./config");
const core_1 = require("./core");
const state_1 = require("./state");
exports.zoomBox = { active: false, x0: 0, y0: 0, x1: 0, y1: 0, kind: 'zoom' };
exports.pointer = {
    down: false, button: 0, sx: 0, sy: 0, lx: 0, ly: 0, tDown: 0, spacePan: false,
};
function attachInteractions(canvas, rect, actions) {
    const dpr = () => window.devicePixelRatio || 1;
    // ---------------- pointer ----------------
    canvas.addEventListener('pointerdown', (e) => {
        if (e.button !== 0 && e.button !== 1 && e.button !== 2)
            return;
        canvas.setPointerCapture(e.pointerId);
        const r = canvas.getBoundingClientRect();
        const sx = e.clientX - r.left;
        const sy = e.clientY - r.top;
        exports.pointer.down = true;
        exports.pointer.button = e.button;
        exports.pointer.sx = exports.pointer.lx = sx;
        exports.pointer.sy = exports.pointer.ly = sy;
        exports.pointer.tDown = performance.now();
        // Middle/right always pan; left depends on toggle (and Space forces pan).
        exports.zoomBox.kind = e.button === 0 && state_1.state.dragMode === 'zoom' && !exports.pointer.spacePan ? 'zoom' : 'pan';
        if (exports.zoomBox.kind === 'zoom') {
            exports.zoomBox.active = true;
            exports.zoomBox.x0 = exports.zoomBox.x1 = sx;
            exports.zoomBox.y0 = exports.zoomBox.y1 = sy;
        }
        else {
            actions.beginPan();
            exports.zoomBox.x0 = exports.zoomBox.x1 = sx;
            exports.zoomBox.y0 = exports.zoomBox.y1 = sy;
        }
        actions.note();
        e.preventDefault();
    });
    canvas.addEventListener('pointermove', (e) => {
        const r = canvas.getBoundingClientRect();
        const sx = e.clientX - r.left;
        const sy = e.clientY - r.top;
        if (!exports.pointer.down) {
            if (state_1.state.mode === 'mandelbrot') {
                const [cre, cim] = (0, core_1.screenToComplex)(sx, sy);
                actions.previewJulia(cre, cim);
            }
            return;
        }
        exports.pointer.lx = sx;
        exports.pointer.ly = sy;
        const dx = sx - exports.pointer.lx;
        const dy = sy - exports.pointer.ly;
        if (exports.zoomBox.kind === 'zoom') {
            exports.zoomBox.x1 = sx;
            exports.zoomBox.y1 = sy;
            updateZoomRect(rect, exports.zoomBox);
        }
        else {
            // pan by last delta
            actions.panTo(core_1.view.re - dx * core_1.view.unitsPerPixel, core_1.view.im + dy * core_1.view.unitsPerPixel);
            exports.zoomBox.x0 = sx;
            exports.zoomBox.y0 = sy;
            exports.zoomBox.x1 = sx;
            exports.zoomBox.y1 = sy;
        }
        actions.note();
    });
    const endPointer = (e) => {
        if (!exports.pointer.down)
            return;
        const wasZoom = exports.zoomBox.kind === 'zoom';
        exports.pointer.down = false;
        hideZoomRect(rect);
        const r = canvas.getBoundingClientRect();
        const sx = e.clientX - r.left;
        const sy = e.clientY - r.top;
        const dist = Math.hypot(sx - exports.pointer.sx, sy - exports.pointer.sy);
        const dur = performance.now() - exports.pointer.tDown;
        if (wasZoom) {
            const w = Math.abs(exports.zoomBox.x1 - exports.zoomBox.x0);
            const h = Math.abs(exports.zoomBox.y1 - exports.zoomBox.y0);
            if (w < config_1.CFG.dragMinPx && h < config_1.CFG.dragMinPx) {
                // Treat as click.
                if (state_1.state.mode === 'mandelbrot') {
                    if (dist < config_1.CFG.clickMaxPx || dur > 500) {
                        const [cre, cim] = (0, core_1.screenToComplex)(sx, sy);
                        actions.setJulia(cre, cim);
                    }
                }
                else {
                    actions.toggleJulia(); // click in Julia mode exits back
                }
            }
            else {
                actions.zoomToRect(exports.zoomBox.x0, exports.zoomBox.y0, exports.zoomBox.x1, exports.zoomBox.y1);
            }
        }
        exports.zoomBox.active = false;
        actions.note();
    };
    canvas.addEventListener('pointerup', endPointer);
    canvas.addEventListener('pointercancel', endPointer);
    canvas.addEventListener('contextmenu', (e) => e.preventDefault());
    // ---------------- wheel ----------------
    let wheelAccum = 0;
    canvas.addEventListener('wheel', (e) => {
        e.preventDefault();
        const r = canvas.getBoundingClientRect();
        const sx = e.clientX - r.left;
        const sy = e.clientY - r.top;
        wheelAccum += e.deltaY;
        const notches = Math.trunc(wheelAccum / config_1.CFG.wheelPixelsPerNotch);
        wheelAccum -= notches * config_1.CFG.wheelPixelsPerNotch;
        if (notches === 0)
            return;
        // positive delta (scroll down) => zoom out => upp grows
        const factor = Math.pow(config_1.CFG.wheelZoomPow, notches);
        (0, state_1.pushHistory)();
        actions.zoomAt(sx, sy, factor);
        actions.note();
    }, { passive: false });
    // ---------------- keyboard ----------------
    window.addEventListener('keydown', (e) => {
        const tag = e.target?.tagName;
        if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
            return;
        const k = e.key;
        if (k === ' ') {
            exports.pointer.spacePan = true;
            e.preventDefault();
            return;
        }
        switch (k) {
            case 'ArrowLeft':
                (0, state_1.pushHistory)();
                actions.panTo(core_1.view.re + config_1.CFG.keyboardPanFrac * core_1.view.width * core_1.view.unitsPerPixel, core_1.view.im);
                actions.note();
                e.preventDefault();
                break;
            case 'ArrowRight':
                (0, state_1.pushHistory)();
                actions.panTo(core_1.view.re - config_1.CFG.keyboardPanFrac * core_1.view.width * core_1.view.unitsPerPixel, core_1.view.im);
                actions.note();
                e.preventDefault();
                break;
            case 'ArrowUp':
                (0, state_1.pushHistory)();
                actions.panTo(core_1.view.re, core_1.view.im + config_1.CFG.keyboardPanFrac * core_1.view.height * core_1.view.unitsPerPixel);
                actions.note();
                e.preventDefault();
                break;
            case 'ArrowDown':
                (0, state_1.pushHistory)();
                actions.panTo(core_1.view.re, core_1.view.im - config_1.CFG.keyboardPanFrac * core_1.view.height * core_1.view.unitsPerPixel);
                actions.note();
                e.preventDefault();
                break;
            case '+':
            case '=':
                zoomKey(true);
                break;
            case '-':
            case '_':
                zoomKey(false);
                break;
            case 'b':
            case 'B':
                actions.back();
                break;
            case 'r':
            case 'R':
                actions.reset();
                break;
            case 'j':
            case 'J':
                actions.toggleJulia();
                break;
            case 'm':
            case 'M':
                actions.toggleBookmarks();
                break;
            case 'h':
            case 'H':
                actions.toggleHelp();
                break;
            case 'Escape':
                actions.escape();
                break;
            case '1':
                actions.setPalette('fire');
                break;
            case '2':
                actions.setPalette('ocean');
                break;
            case '3':
                actions.setPalette('mono');
                break;
            case '4':
                actions.setPalette('psy');
                break;
            case '[':
                actions.adjustIters(-50);
                break;
            case ']':
                actions.adjustIters(50);
                break;
            default: return;
        }
    });
    window.addEventListener('keyup', (e) => {
        if (e.key === ' ')
            exports.pointer.spacePan = false;
    });
    function zoomKey(zoomIn) {
        (0, state_1.pushHistory)();
        actions.zoomAt(core_1.view.width / 2, core_1.view.height / 2, zoomIn ? 1 / config_1.CFG.keyboardZoomPow : config_1.CFG.keyboardZoomPow);
        actions.note();
    }
}
// ============================================================
// Zoom-box overlay helpers
// ============================================================
function updateZoomRect(el, b) {
    const x = Math.min(b.x0, b.x1);
    const y = Math.min(b.y0, b.y1);
    const w = Math.abs(b.x1 - b.x0);
    const h = Math.abs(b.y1 - b.y0);
    el.style.display = 'block';
    el.style.left = x + 'px';
    el.style.top = y + 'px';
    el.style.width = w + 'px';
    el.style.height = h + 'px';
}
function hideZoomRect(el) {
    el.style.display = 'none';
}
// ============================================================
// View math actions (pure — shared by UI + debug interface)
// ============================================================
function zoomAtPoint(sx, sy, factor) {
    const [kre, kim] = (0, core_1.screenToComplex)(sx, sy);
    const newUpp = clampZoomUpp(core_1.view.unitsPerPixel * factor);
    core_1.view.unitsPerPixel = newUpp;
    // keep the anchor point under the cursor
    core_1.view.re = kre - (sx - core_1.view.width / 2) * newUpp;
    core_1.view.im = kim + (sy - core_1.view.height / 2) * newUpp;
}
/** Drag-zoom into the EXACT rect: aspect is handled by expanding (fit),
 *  never cropping — the framed region is the region rendered. */
function zoomToRect(x0, y0, x1, y1) {
    const cx = (x0 + x1) / 2;
    const cy = (y0 + y1) / 2;
    const [reC, imC] = (0, core_1.screenToComplex)(cx, cy);
    const w = Math.abs(x1 - x0);
    const h = Math.abs(y1 - y0);
    // Fit the whole rect inside the canvas (expand the smaller dimension):
    //   new upp = max( boxWidth_inUnits / viewWidth, boxHeight_inUnits / viewHeight )
    // so the framed region is exactly the region rendered — never cropped.
    const uppW = (w * core_1.view.unitsPerPixel) / core_1.view.width;
    const uppH = (h * core_1.view.unitsPerPixel) / core_1.view.height;
    const upp = clampZoomUpp(Math.max(uppW, uppH));
    core_1.view.re = reC;
    core_1.view.im = imC;
    core_1.view.unitsPerPixel = upp;
}
function clampZoomUpp(upp) {
    const m = 1 / upp;
    if (m > config_1.CFG.maxMagnification)
        return 1 / config_1.CFG.maxMagnification;
    if (m < 0.05)
        return 1 / 0.05;
    return upp;
}
};

// ── module: src/ui.ts ──
__mods["src/ui.ts"] = function (exports, require, module) {
"use strict";
// ============================================================
// UI — HUD, mode badge, toast, palette chips, bookmark panel,
//      Julia preview pane, idle-hide
// ============================================================
Object.defineProperty(exports, "__esModule", { value: true });
exports.UI = void 0;
exports.setPreviewLut = setPreviewLut;
exports.fmtMag = fmtMag;
exports.sameViewState = sameViewState;
const config_1 = require("./config");
const core_1 = require("./core");
const state_1 = require("./state");
const $ = (id) => document.getElementById(id);
class UI {
    constructor(applyView) {
        this.lastHud = '';
        this.toastTimer = null;
        this.idleTimer = null;
        this.fpsEma = 0;
        this.fpsTick = 0;
        this.bookmarks = (0, state_1.loadBookmarks)();
        this.applyView = applyView;
        this.hud = $('hud');
        this.hudMag = $('hudMag');
        this.hudRe = $('hudRe');
        this.hudIm = $('hudIm');
        this.hudIters = $('hudIters');
        this.hudFps = $('hudFps');
        this.badge = $('modeBadge');
        this.toastEl = $('toast');
        this.bookmarksEl = $('bookmarks');
        this.bmList = $('bmList');
        this.helpEl = $('help');
        this.previewWrap = $('previewWrap');
        this.previewCanvas = $('preview');
        const ctx = this.previewCanvas.getContext('2d');
        if (!ctx)
            throw new Error('preview ctx');
        this.previewCtx = ctx;
    }
    // ---------------- HUD ----------------
    update(fps) {
        const m = (0, core_1.mag)();
        const magStr = '×' + fmtMag(m);
        const re = core_1.view.re.toFixed(12);
        const im = core_1.view.im.toFixed(12);
        const it = String((0, state_1.currentMaxIter)());
        const fpsStr = fps > 0 ? String(Math.round(fps)) : '–';
        const key = magStr + re + im + it + fpsStr;
        if (key === this.lastHud)
            return;
        this.lastHud = key;
        this.hudMag.textContent = magStr;
        this.hudRe.textContent = re;
        this.hudIm.textContent = im;
        this.hudIters.textContent = it + (state_1.state.iterOverride !== null ? ' (manual)' : '');
        this.hudFps.textContent = fpsStr;
    }
    setMode(mode, cRe, cIm) {
        this.badge.classList.toggle('julia', mode === 'julia');
        this.badge.textContent = mode === 'julia' ? `Julia  c = ${cRe.toFixed(6)} ${cIm >= 0 ? '+' : '−'} ${Math.abs(cIm).toFixed(6)}` : 'Mandelbrot';
    }
    // ---------------- idle hide ----------------
    poke() {
        this.idle();
    }
    idle() {
        for (const el of [this.hud, this.badge, $('dock')])
            el?.classList.remove('idle');
        if (this.idleTimer)
            window.clearTimeout(this.idleTimer);
        this.idleTimer = window.setTimeout(() => {
            for (const el of [this.hud, this.badge, $('dock')])
                el?.classList.add('idle');
        }, config_1.CFG.idleHideMs);
    }
    // ---------------- toast ----------------
    toast(msg, ms = 2600) {
        this.toastEl.textContent = msg;
        this.toastEl.classList.add('show');
        if (this.toastTimer)
            window.clearTimeout(this.toastTimer);
        this.toastTimer = window.setTimeout(() => this.toastEl.classList.remove('show'), ms);
    }
    // ---------------- palette chips ----------------
    setPaletteChips(name) {
        for (const key of Object.keys(config_1.PALETTES)) {
            const el = $(`chip${key[0].toUpperCase()}${key.slice(1)}`);
            if (!el)
                continue;
            el.classList.toggle('active', key === name);
        }
    }
    wirePaletteChips(onSelect) {
        for (const key of Object.keys(config_1.PALETTES)) {
            const el = $(`chip${key[0].toUpperCase()}${key.slice(1)}`);
            if (el)
                el.addEventListener('click', () => onSelect(key));
        }
    }
    // ---------------- Julia preview pane ----------------
    /** Render the live Julia preview for c (small image, full iterations). */
    renderPreview(cRe, cIm, palName, offset, iters) {
        const size = config_1.CFG.previewSize;
        if (this.previewCanvas.width !== size) {
            this.previewCanvas.width = size;
            this.previewCanvas.height = size;
        }
        const s = size;
        const span = config_1.CFG.juliaSpan;
        const upp = span / s;
        const x0 = cRe - (s / 2) * upp;
        const yTop = cIm + (s / 2) * upp;
        const img = this.previewCtx.createImageData(s, s);
        const px = new Uint32Array(img.data.buffer);
        for (let py = 0; py < s; py++) {
            const im0 = yTop - py * upp;
            let i = py * s;
            for (let pxX = 0; pxX < s; pxX++, i++) {
                const re0 = x0 + pxX * upp;
                // escape iterations inline (allocation-free)
                let zr = re0, zi = im0, n = 0;
                let escaped = false;
                for (; n < iters; n++) {
                    const t = 2 * zr * zi;
                    zr = zr * zr - zi * zi + cRe;
                    zi = t + cIm;
                    const m2 = zr * zr + zi * zi;
                    if (m2 > 256) {
                        const logZ = Math.log(m2) / 2;
                        const nu = Math.log(logZ) / Math.LN2;
                        const v = n + 1 - nu;
                        const t2 = Math.sqrt(Math.min(1, v / iters)) * 2048 + offset * 2048;
                        px[i] = previewLut[(t2 < 0 ? (t2 + 2048) | 0 : t2 | 0) % 2048];
                        escaped = true;
                        break;
                    }
                }
                if (!escaped)
                    px[i] = 0xff000000;
            }
        }
        this.previewCtx.putImageData(img, 0, 0);
        void palName;
    }
    showPreview(on) {
        this.previewWrap.classList.toggle('hidden', !on);
    }
    previewHidden() {
        return this.previewWrap.classList.contains('hidden');
    }
    // ---------------- bookmarks ----------------
    toggleBookmarks() {
        this.bookmarksEl.classList.toggle('open');
        if (this.bookmarksEl.classList.contains('open'))
            this.renderBookmarkList();
    }
    bookmarkOpen() {
        return this.bookmarksEl.classList.contains('open');
    }
    renderBookmarkList() {
        this.bmList.innerHTML = '';
        for (const bm of this.bookmarks) {
            const row = document.createElement('div');
            row.className = 'bmRow';
            const name = document.createElement('span');
            name.className = 'name';
            name.textContent = bm.name;
            name.title = bm.name;
            name.addEventListener('click', () => {
                this.applyView({
                    re: bm.re, im: bm.im, upp: bm.upp, mode: bm.mode,
                    cRe: bm.cRe, cIm: bm.cIm, pal: bm.pal, offset: bm.offset, maxIter: bm.maxIter,
                });
                this.toast(`Restored “${bm.name}”`);
            });
            const meta = document.createElement('span');
            meta.className = 'meta';
            meta.textContent = `×${fmtMag(1 / bm.upp)}`;
            const del = document.createElement('button');
            del.className = 'del';
            del.textContent = '✕';
            del.title = 'Delete bookmark';
            del.addEventListener('click', () => {
                this.bookmarks = this.bookmarks.filter((b) => b !== bm);
                (0, state_1.saveBookmarks)(this.bookmarks);
                this.renderBookmarkList();
            });
            row.append(name, meta, del);
            this.bmList.appendChild(row);
        }
        if (this.bookmarks.length === 0) {
            const empty = document.createElement('div');
            empty.className = 'bmhint';
            empty.textContent = 'No bookmarks yet — name one and hit Save.';
            this.bmList.appendChild(empty);
        }
    }
    addBookmark(name, v) {
        this.bookmarks.push({
            name: name || `View ${this.bookmarks.length + 1}`,
            re: v.re, im: v.im, upp: v.upp, mode: v.mode,
            cRe: v.cRe, cIm: v.cIm, pal: v.pal, offset: v.offset, maxIter: v.maxIter,
            t: Date.now(),
        });
        (0, state_1.saveBookmarks)(this.bookmarks);
        this.renderBookmarkList();
    }
    // ---------------- help ----------------
    toggleHelp() {
        this.helpEl.classList.toggle('open');
    }
    helpOpen() {
        return this.helpEl.classList.contains('open');
    }
    wireHelpClose() {
        const btn = $('btnCloseHelp');
        btn?.addEventListener('click', () => this.helpEl.classList.remove('open'));
        this.helpEl.addEventListener('click', (e) => {
            if (e.target === this.helpEl)
                this.helpEl.classList.remove('open');
        });
    }
}
exports.UI = UI;
// Shared LUT used by the preview (set from main when the palette changes).
let previewLut = new Uint32Array(2048);
function setPreviewLut(l) {
    previewLut = l;
}
function fmtMag(m) {
    if (m >= 1e5 || m < 1e-4)
        return m.toExponential(2);
    if (m >= 1000)
        return m.toLocaleString('en-US', { maximumFractionDigits: 0 });
    if (m >= 10)
        return m.toFixed(1);
    return m.toFixed(3);
}
function sameViewState(a, b) {
    return (0, state_1.sameView)(a, b);
}
};

// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"typescript","sessionId":"78145de3-1568-49a9-9525-1bd8afa44d00","tokensIn":12586123,"tokensOut":223924,"tokensTotal":12810047,"cost":0,"turns":158,"toolCalls":199,"failedToolCalls":9,"timestamp":"2026-09-09T03:48:36.541Z"} -->