Fractal Explorer
Qwen 3.8 · typescript

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