Fractal Explorer
Qwen 3.6 27b MTP · composer

Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 53,823 bytes · SHA-256 19d68d88a85e
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fractal Explorer — Mandelbrot & Julia Sets</title>
<style>
/* ── css: layout ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; color: #fff; }
#viewport { position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: crosshair; }
#fractal-canvas { display: block; width: 100%; height: 100%; image-rendering: auto; }
/* Zoom overlay rectangle */
#zoom-overlay { position: absolute; border: 2px solid rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); pointer-events: none; display: none; }
/* HUD */
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s ease; }
#hud.hidden-hud { opacity: 0; }
#hud-top { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; pointer-events: auto; }
#info-panel { display: flex; gap: 16px; background: rgba(0,0,0,0.5); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; padding: 6px 14px; font-size: 13px; font-family: 'SF Mono', 'Fira Code', monospace; }
#info-panel span { white-space: nowrap; }
#controls-top { display: flex; gap: 6px; pointer-events: auto; }
#controls-top button, #hud-bottom button { background: rgba(255,255,255,0.1); backdrop-filter: blur(8px); border: 1px solid rgba(255,255,255,0.15); color: #fff; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; transition: background 0.2s; }
#controls-top button:hover, #hud-bottom button:hover { background: rgba(255,255,255,0.2); }
#controls-top button.active { background: rgba(100,180,255,0.3); border-color: rgba(100,180,255,0.5); }
/* Bottom HUD */
#hud-bottom { position: absolute; bottom: 0; left: 0; width: 100%; display: flex; align-items: center; justify-content: center; gap: 16px; padding: 12px 16px; pointer-events: auto; background: linear-gradient(transparent, rgba(0,0,0,0.5)); }
/* Palette bar */
#palette-bar { display: flex; gap: 8px; align-items: center; }
.palette-swatch { width: 28px; height: 28px; border-radius: 6px; cursor: pointer; border: 2px solid transparent; transition: all 0.2s; position: relative; overflow: hidden; }
.palette-swatch:hover { transform: scale(1.15); }
.palette-swatch.active { border-color: #fff; box-shadow: 0 0 8px rgba(255,255,255,0.4); }
/* Sliders */
input[type="range"] { -webkit-appearance: none; appearance: none; height: 6px; background: rgba(255,255,255,0.15); border-radius: 3px; outline: none; cursor: pointer; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: #fff; border-radius: 50%; cursor: pointer; }
#color-offset { width: 120px; }
#iter-slider { width: 140px; }
/* Bookmark panel */
#bookmark-panel { position: absolute; top: 60px; right: 16px; background: rgba(0,0,0,0.7); backdrop-filter: blur(16px); border: 1px solid rgba(255,255,255,0.15); border-radius: 10px; padding: 16px; min-width: 240px; pointer-events: auto; z-index: 10; }
#bookmark-panel h3 { font-size: 14px; margin-bottom: 8px; opacity: 0.7; text-transform: uppercase; letter-spacing: 1px; }
#bookmark-list { max-height: 200px; overflow-y: auto; margin-bottom: 8px; }
.bookmark-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 8px; border-radius: 4px; cursor: pointer; font-size: 13px; transition: background 0.2s; }
.bookmark-item:hover { background: rgba(255,255,255,0.1); }
.bookmark-item button { background: none; border: none; color: #f66; cursor: pointer; font-size: 14px; padding: 2px 6px; }
#bookmark-name { width: 100%; padding: 6px 8px; margin-bottom: 8px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; color: #fff; font-size: 13px; outline: none; }
#bookmark-name::placeholder { color: rgba(255,255,255,0.4); }
/* Julia preview label */
#julia-preview-label { position: absolute; top: 60px; left: 50%; transform: translateX(-50%); background: rgba(100,180,255,0.3); backdrop-filter: blur(8px); border: 1px solid rgba(100,180,255,0.4); border-radius: 6px; padding: 4px 12px; font-size: 12px; font-family: monospace; pointer-events: none; }
/* Utility */
.hidden { display: none !important; }
/* Scrollbar styling */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 3px; }
/* ── css: crosshair_and_hud_enhancements ── */
/* Crosshair overlay */
#crosshair { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.ch-line { position: absolute; background: rgba(255,255,255,0.3); }
.ch-h { width: 100%; height: 1px; top: 50%; left: 0; }
.ch-v { width: 1px; height: 100%; top: 0; left: 50%; }
.ch-dot { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: rgba(255,255,255,0.6); top: calc(50% - 4px); left: calc(50% - 4px); }
/* HUD improvements */
#info-panel { font-size: 12px; letter-spacing: 0.3px; }
#mag-display { color: #7df; }
#center-display { color: #cfc; }
#iter-display { color: #fcc; }
#fps-display { color: #ffc; }
/* Zoom overlay with inverted border effect */
#zoom-overlay {
position: absolute;
border: 2px solid rgba(255,255,255,0.9);
background: rgba(255,255,255,0.03);
pointer-events: none;
display: none;
box-shadow: 0 0 0 1px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(0,0,0,0.3);
}
/* Slider labels */
#hud-bottom { gap: 24px; }
.slider-group { display: flex; align-items: center; gap: 6px; font-size: 11px; color: rgba(255,255,255,0.6); }
/* Keyboard hints */
#key-hints { position: absolute; bottom: 50px; right: 16px; font-size: 10px; color: rgba(255,255,255,0.3); text-align: right; line-height: 1.6; pointer-events: none; }
</style>
</head>
<body>
<div id="viewport">
<canvas id="fractal-canvas"></canvas>
<div id="zoom-overlay" class="hidden"></div>
<div id="crosshair" class="hidden">
<div class="ch-line ch-h"></div>
<div class="ch-line ch-v"></div>
<div class="ch-dot"></div>
</div></div>
<div id="hud">
<div id="hud-top">
<span id="mag-display">×1.0</span>
<span id="center-display">(-0.5, 0)</span>
<span id="iter-display">Iter: 200</span>
<span id="fps-display">60 FPS</span>
<span id="mode-display" style="color:#f9a;font-weight:bold;">Mandelbrot</span>
<div id="controls-top">
<button id="btn-reset" title="Reset View (R)">⟲</button>
<button id="btn-back" title="Zoom Back (Z)" class="hidden">←</button>
<button id="btn-julia" title="Julia Mode (J)">J</button>
</div>
</div>
<div id="palette-bar"></div>
<div class="slider-group">
<label for="color-offset">Color</label>
<input type="range" id="color-offset" min="0" max="1" step="0.001" value="0" title="Color Offset (C)">
</div>
<div class="slider-group">
<label for="iter-slider">Iter</label>
<input type="range" id="iter-slider" min="50" max="2000" step="10" value="200" title="Max Iterations (I)">
</div>
<div id="key-hints">
←→↑↓ pan · +/- zoom<br>
drag pan · long-drag zoom box<br>
click Mandelbrot → Julia<br>
J toggle · Z back · R reset · B bookmarks
</div>
</div>
<div id="bookmark-panel" class="hidden">
<h3>Bookmarks</h3>
<div id="bookmark-list"></div>
<input type="text" id="bookmark-name" placeholder="Bookmark name...">
<button id="btn-save-bookmark">Save View</button>
<button id="btn-close-bookmarks">Close (B)</button>
</div>
<div id="julia-preview-label" class="hidden">Julia Set Preview — c = <span id="julia-c-display"></span></div>
<script>
'use strict';
// ── const: CONFIG ──
// ============================================================
// TUNABLE CONSTANTS
// ============================================================
const CONFIG = {
// Iteration limits
baseIterations: 200,
iterationsPerZoomLog: 80, // extra iterations per log10(magnification)
maxIterations: 5000, // hard cap
// Zoom behavior
zoomSpeed: 0.3, // wheel zoom factor (e^zoomSpeed - 1)
dragZoomMinPixels: 8, // minimum drag distance to trigger rectangle zoom
panDragThreshold: 4, // pixels before we consider it a pan vs click
// Progressive rendering
progressivePasses: [0.25, 0.5, 1.0], // resolution fractions per pass
tileSize: 64, // worker tile size in pixels
// Precision limits
maxMagnification: 1e13, // beyond this, double precision degrades visibly
// HUD auto-hide delay (ms)
hudHideDelay: 3000,
// Julia preview update throttle (ms)
juliaPreviewThrottle: 50,
// FPS tracking window
fpsWindowFrames: 60,
};
// ── const: PALETTES ──
// ============================================================
// PALETTE DEFINITIONS
// Each palette is an array of {pos, r, g, b} stops for interpolation.
// pos ranges from 0 to 1.
// ============================================================
const PALETTES = {
fire: {
name: 'Fire',
gradient: [
{ pos: 0.0, r: 0, g: 0, b: 0 },
{ pos: 0.15, r: 80, g: 0, b: 0 },
{ pos: 0.3, r: 200, g: 40, b: 0 },
{ pos: 0.5, r: 255, g: 160, b: 0 },
{ pos: 0.7, r: 255, g: 240, b: 80 },
{ pos: 0.9, r: 255, g: 255, b: 220 },
{ pos: 1.0, r: 255, g: 255, b: 255 },
],
swatchColors: ['#000', '#500', '#c28', '#fa0', '#ff6', '#fff'],
},
ocean: {
name: 'Ocean',
gradient: [
{ pos: 0.0, r: 0, g: 10, b: 40 },
{ pos: 0.2, r: 0, g: 30, b: 100 },
{ pos: 0.4, r: 0, g: 80, b: 160 },
{ pos: 0.6, r: 20, g: 150, b: 200 },
{ pos: 0.8, r: 100, g: 220, b: 240 },
{ pos: 1.0, r: 200, g: 255, b: 255 },
],
swatchColors: ['#00a', '#06c', '#08a', '#29c', '#6dc', '#cff'],
},
monochrome: {
name: 'Mono',
gradient: [
{ pos: 0.0, r: 0, g: 0, b: 0 },
{ pos: 1.0, r: 255, g: 255, b: 255 },
],
swatchColors: ['#000', '#444', '#888', '#ccc', '#fff'],
},
psychedelic: {
name: 'Psychedelic',
gradient: [
{ pos: 0.0, r: 128, g: 0, b: 128 },
{ pos: 0.16,r: 0, g: 0, b: 255 },
{ pos: 0.33,r: 0, g: 255, b: 255 },
{ pos: 0.5, r: 0, g: 255, b: 0 },
{ pos: 0.66,r: 255, g: 255, b: 0 },
{ pos: 0.83,r: 255, g: 0, b: 0 },
{ pos: 1.0, r: 128, g: 0, b: 128 },
],
swatchColors: ['#808', '#00f', '#0ff', '#0f0', '#ff0', '#f00'],
},
electric: {
name: 'Electric',
gradient: [
{ pos: 0.0, r: 5, g: 5, b: 30 },
{ pos: 0.2, r: 20, g: 0, b: 120 },
{ pos: 0.4, r: 80, g: 0, b: 200 },
{ pos: 0.6, r: 150, g: 50, b: 255 },
{ pos: 0.8, r: 220, g: 150, b: 255 },
{ pos: 1.0, r: 255, g: 240, b: 255 },
],
swatchColors: ['#003', '#148', '#50c', '#96f', '#d9f', '#faf'],
},
};
// ── const: PALETTE_KEYS ──
const PALETTE_KEYS = Object.keys(PALETTES);
// ── function: mandelbrotIteration ──
// ============================================================
// MATH UTILITIES & COLOR FUNCTIONS
// ============================================================
/**
* Compute Mandelbrot iteration count for a point c = (cre, cim).
* Returns the smooth (continuous) iteration count.
* If the point is in the set (doesn't escape), returns maxIter + 1.
*/
function mandelbrotIteration(cre, cim, maxIter) {
let zr = 0, zi = 0;
let zr2 = 0, zi2 = 0;
for (let n = 0; n < maxIter; n++) {
// z = z^2 + c: (zr+izi)^2 = zr^2 - zi^2 + i*2*zr*zi
zi = 2 * zr * zi + cim;
zr = zr2 - zi2 + cre;
zr2 = zr * zr;
zi2 = zi * zi;
if (zr2 + zi2 > 256) {
// Smooth iteration count: n + 1 - log2(log|z|)
const log_zn = Math.log(zr2 + zi2) * 0.5;
return n + 1 - Math.log(log_zn) / Math.LN2;
}
}
return maxIter + 1; // interior point
}
// ── function: juliaIteration ──
/**
* Compute Julia set iteration count for a point z = (zre, zim) with parameter c.
*/
function juliaIteration(zre, zim, cre, cim, maxIter) {
let zr = zre, zi = zim;
let zr2 = zr * zr, zi2 = zi * zi;
for (let n = 0; n < maxIter; n++) {
zi = 2 * zr * zi + cim;
zr = zr2 - zi2 + cre;
zr2 = zr * zr;
zi2 = zi * zi;
if (zr2 + zi2 > 256) {
const log_zn = Math.log(zr2 + zi2) * 0.5;
return n + 1 - Math.log(log_zn) / Math.LN2;
}
}
return maxIter + 1;
}
// ── function: samplePalette ──
/**
* Interpolate a palette value at position t (0..1), with optional cyclic offset.
* Returns {r, g, b}.
*/
function samplePalette(paletteKey, t, colorOffset) {
const pal = PALETTES[paletteKey];
if (!pal) return { r: 0, g: 0, b: 0 };
// Apply cyclic offset (wraps around for psychedelic, clamps for others)
let pos;
if (paletteKey === 'psychedelic') {
pos = ((t + colorOffset) % 1.0 + 1.0) % 1.0;
} else {
pos = Math.max(0, Math.min(1, t + colorOffset));
}
const stops = pal.gradient;
// Find surrounding stops
let lo = stops[0], hi = stops[stops.length - 1];
for (let i = 0; i < stops.length - 1; i++) {
if (pos >= stops[i].pos && pos <= stops[i + 1].pos) {
lo = stops[i];
hi = stops[i + 1];
break;
}
}
// Interpolate between stops
const range = hi.pos - lo.pos;
const frac = range > 0 ? (pos - lo.pos) / range : 0;
return {
r: Math.round(lo.r + (hi.r - lo.r) * frac),
g: Math.round(lo.g + (hi.g - lo.g) * frac),
b: Math.round(lo.b + (hi.b - lo.b) * frac),
};
}
// ── function: computeIterations ──
/**
* Compute effective iteration count based on zoom depth.
*/
function computeIterations(magnification, manualOverride) {
if (manualOverride !== null && manualOverride !== undefined) return manualOverride;
const logMag = Math.log10(Math.max(1, magnification));
return Math.min(CONFIG.maxIterations, Math.round(CONFIG.baseIterations + CONFIG.iterationsPerZoomLog * logMag));
}
// ── function: pixelToComplex ──
/**
* Convert canvas pixel coordinates to complex plane coordinates.
*/
function pixelToComplex(px, py, canvasW, canvasH, centerRe, centerIm, magnification) {
const aspect = canvasW / canvasH;
// Viewport: width in complex plane is proportional to 1/magnification
const viewHeight = 3.0 / magnification;
const viewWidth = viewHeight * aspect;
return {
re: centerRe + (px / canvasW - 0.5) * viewWidth,
im: centerIm + (py / canvasH - 0.5) * viewHeight,
};
}
// ── function: complexToPixel ──
/**
* Convert complex plane coordinates to canvas pixel coordinates.
*/
function complexToPixel(re, im, canvasW, canvasH, centerRe, centerIm, magnification) {
const aspect = canvasW / canvasH;
const viewHeight = 3.0 / magnification;
const viewWidth = viewHeight * aspect;
return {
x: (re - centerRe) / viewWidth * canvasW + canvasW * 0.5,
y: (im - centerIm) / viewHeight * canvasH + canvasH * 0.5,
};
}
// ── function: createWorker ──
// ============================================================
// RENDERER — ImageData pipeline with Web Worker tiles & progressive rendering
// ============================================================
/**
* Create a Web Worker from inline code using a Blob URL.
*/
function createWorker(code) {
const blob = new Blob([code], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
}
// ── function: paletteToWorkerFormat ──
/**
* Convert palette gradient to compact array format for worker.
*/
function paletteToWorkerFormat(paletteKey) {
const pal = PALETTES[paletteKey];
return pal.gradient.map(s => [s.pos, s.r, s.g, s.b]);
}
// ── class: FractalRenderer ──
// ============================================================
// RENDERER — ImageData pipeline with Web Worker tiles & progressive rendering
// ============================================================
class FractalRenderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.renderId = 0; // incremented on each new render to invalidate old tiles
this.isRendering = false;
this.numWorkers = Math.min(navigator.hardwareConcurrency || 4, 8);
// Create workers once at startup
this.workers = [];
for (let i = 0; i < this.numWorkers; i++) {
const w = createWorker(WORKER_SCRIPT);
// Send palette data to worker as init messages
for (const key of PALETTE_KEYS) {
w.postMessage({ __init: true, paletteKey: key, data: paletteToWorkerFormat(key) });
}
this.workers.push(w);
}
// Setup message handlers — workers check renderId to ignore stale tiles
this.workers.forEach(w => {
w.onmessage = (e) => {
const d = e.data;
if (d.__init) return; // init ack, ignore
if (d.renderId !== this.renderId) return; // stale tile from cancelled render
this._handleTileResult(d);
};
});
this._tempCanvas = document.createElement('canvas');
}
/**
* Start rendering with given view parameters.
* Cancels any in-flight render first (by incrementing renderId).
*/
startRender(centerRe, centerIm, magnification, maxIter, paletteKey, colorOffset, setMode, juliaCre, juliaCim) {
// Cancel previous render by bumping the ID — old tiles will be ignored
this.renderId++;
this.isRendering = true;
const currentRenderId = this.renderId;
this.viewState = { centerRe, centerIm, magnification, maxIter, paletteKey, colorOffset, setMode: setMode || 'mandelbrot', juliaCre: juliaCre || 0, juliaCim: juliaCim || 0 };
const canvasW = this.canvas.width;
const canvasH = this.canvas.height;
// Progressive rendering: start with coarse pass
this._currentPassIndex = 0;
this._renderPass(canvasW, canvasH);
}
_renderPass(canvasW, canvasH) {
if (this.renderId !== this.viewState.__renderId && !('centerRe' in this.viewState)) return;
const resolution = CONFIG.progressivePasses[this._currentPassIndex];
const renderW = Math.max(1, Math.round(canvasW * resolution));
const renderH = Math.max(1, Math.round(canvasH * resolution));
// Create image data at render resolution
this._renderImageData = new ImageData(renderW, renderH);
this._pendingTiles = 0;
this._completedTiles = 0;
this._currentResolution = resolution;
const tileSize = CONFIG.tileSize;
const tilesX = Math.ceil(renderW / tileSize);
const tilesY = Math.ceil(renderH / tileSize);
// Distribute tiles to workers in a round-robin fashion
let workerIdx = 0;
for (let ty = 0; ty < tilesY; ty++) {
for (let tx = 0; tx < tilesX; tx++) {
const tileW = Math.min(tileSize, renderW - tx * tileSize);
const tileH = Math.min(tileSize, renderH - ty * tileSize);
this.workers[workerIdx].postMessage({
renderId: this.renderId,
tileX: tx * tileSize,
tileY: ty * tileSize,
tileW, tileH,
canvasW: renderW,
canvasH: renderH,
centerRe: this.viewState.centerRe,
centerIm: this.viewState.centerIm,
magnification: this.viewState.magnification,
maxIter: this.viewState.maxIter,
paletteKey: this.viewState.paletteKey,
colorOffset: this.viewState.colorOffset,
setMode: this.viewState.setMode,
juliaCre: this.viewState.juliaCre,
juliaCim: this.viewState.juliaCim,
});
this._pendingTiles++;
workerIdx = (workerIdx + 1) % this.numWorkers;
}
}
// If no tiles needed, blit immediately
if (tilesX === 0 || tilesY === 0) {
this.isRendering = false;
if (this.onRenderComplete) this.onRenderComplete();
}
}
_handleTileResult(data) {
const { tileX, tileY, tileW, tileH, data: buffer } = data;
const pixels = new Uint8ClampedArray(buffer);
// Copy tile into render image data
const renderW = this._renderImageData.width;
const dstData = this._renderImageData.data;
for (let ty = 0; ty < tileH; ty++) {
const srcRow = ty * tileW * 4;
const dstRow = ((tileY + ty) * renderW + tileX) * 4;
for (let tx = 0; tx < tileW; tx++) {
const si = srcRow + tx * 4;
const di = dstRow + tx * 4;
dstData[di] = pixels[si];
dstData[di + 1] = pixels[si + 1];
dstData[di + 2] = pixels[si + 2];
dstData[di + 3] = pixels[si + 3];
}
}
this._completedTiles++;
// If all tiles done, blit and move to next pass
if (this._completedTiles >= this._pendingTiles) {
this._blit();
this._currentPassIndex++;
if (this._currentPassIndex < CONFIG.progressivePasses.length) {
requestAnimationFrame(() => this._renderPass(this.canvas.width, this.canvas.height));
} else {
this.isRendering = false;
if (this.onRenderComplete) this.onRenderComplete();
}
}
}
_blit() {
const canvasW = this.canvas.width;
const canvasH = this.canvas.height;
// Draw the render image data scaled to full canvas size
this._tempCanvas.width = this._renderImageData.width;
this._tempCanvas.height = this._renderImageData.height;
const tempCtx = this._tempCanvas.getContext('2d');
tempCtx.putImageData(this._renderImageData, 0, 0);
// Disable image smoothing for crisp pixels at full resolution
if (this._currentResolution >= 1.0) {
this.ctx.imageSmoothingEnabled = false;
} else {
this.ctx.imageSmoothingEnabled = true;
this.ctx.imageSmoothingQuality = 'medium';
}
this.ctx.drawImage(this._tempCanvas, 0, 0, canvasW, canvasH);
}
/**
* Re-colorize the current view with a new palette or color offset.
* Does NOT recompute iterations — only changes the color mapping.
*/
recolor(paletteKey, colorOffset) {
if (!this._renderImageData || this.isRendering) return;
const data = this._renderImageData.data;
const w = this._renderImageData.width;
const h = this._renderImageData.height;
const maxIter = this.viewState ? this.viewState.maxIter : 200;
for (let i = 0; i < w * h; i++) {
const idx = i * 4;
// Check if interior point (black)
if (data[idx] === 0 && data[idx+1] === 0 && data[idx+2] === 0) continue;
// Reconstruct approximate iteration value from color — this is lossy.
// For a proper recolor, we'd need to store iteration values separately.
// Instead, just re-render with new palette (fast since iterations are cached in workers).
// Actually, let's just do a full re-render for correctness.
}
// Full re-render is the correct approach for recoloring
if (this.viewState) {
this.startRender(
this.viewState.centerRe, this.viewState.centerIm,
this.viewState.magnification, this.viewState.maxIter,
paletteKey, colorOffset,
this.viewState.setMode, this.viewState.juliaCre, this.viewState.juliaCim
);
}
}
destroy() {
this.renderId++; // cancel any in-flight work
this.workers.forEach(w => w.terminate());
this.workers = [];
}
}
// ── function: buildPaletteBar ──
// ============================================================
// UI SETUP & EVENT HANDLING
// ============================================================
/**
* Build palette swatch chips for the HUD.
*/
function buildPaletteBar(app) {
const bar = document.getElementById('palette-bar');
if (!bar) return;
bar.innerHTML = '';
PALETTE_KEYS.forEach(key => {
const pal = PALETTES[key];
const swatch = document.createElement('div');
swatch.className = 'palette-swatch' + (key === app.paletteKey ? ' active' : '');
swatch.title = pal.name;
swatch.setAttribute('role', 'button');
swatch.setAttribute('tabindex', '0');
swatch.setAttribute('aria-label', `Palette: ${pal.name}`);
// Create gradient background from palette stops
const colors = pal.swatchColors || [];
if (colors.length > 1) {
swatch.style.background = `linear-gradient(90deg, ${colors.join(', ')})`;
} else {
swatch.style.background = '#888';
}
swatch.addEventListener('click', () => {
app.paletteKey = key;
buildPaletteBar(app);
app.render();
});
swatch.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
app.paletteKey = key;
buildPaletteBar(app);
app.render();
}
});
bar.appendChild(swatch);
});
}
// ── function: toggleBookmarkPanel ──
/**
* Toggle bookmark panel visibility.
*/
function toggleBookmarkPanel(app) {
const panel = document.getElementById('bookmark-panel');
if (!panel) return;
const isHidden = panel.classList.contains('hidden');
panel.classList.toggle('hidden', !isHidden);
if (isHidden) renderBookmarkList(app);
}
// ── function: renderBookmarkList ──
/**
* Render the bookmark list in the UI.
*/
function renderBookmarkList(app) {
const listEl = document.getElementById('bookmark-list');
if (!listEl) return;
listEl.innerHTML = '';
const bookmarks = app.loadBookmarks();
bookmarks.forEach((b, i) => {
const item = document.createElement('div');
item.className = 'bookmark-item';
const label = document.createElement('span');
label.textContent = b.name;
if (b.setMode === 'julia') label.textContent += ' [J]';
item.appendChild(label);
const delBtn = document.createElement('button');
delBtn.textContent = '×';
delBtn.title = 'Delete bookmark';
delBtn.addEventListener('click', (e) => {
e.stopPropagation();
app.deleteBookmark(i);
renderBookmarkList(app);
});
item.appendChild(delBtn);
item.addEventListener('click', () => {
app.loadBookmark(i);
// Update UI controls to match loaded state
const iterSlider = document.getElementById('iter-slider');
if (iterSlider && b.maxIterOverride) iterSlider.value = b.maxIterOverride;
const colorSlider = document.getElementById('color-offset');
if (colorSlider) colorSlider.value = b.colorOffset || 0;
buildPaletteBar(app);
});
listEl.appendChild(item);
});
}
// ── const: WORKER_SCRIPT ──
const WORKER_SCRIPT = `
var PALETTE_DATA = {};
self.onmessage = function(e) {
var d = e.data;
// Handle init messages (palette data)
if (d.__init) {
PALETTE_DATA[d.paletteKey] = d.data;
return;
}
var tileX = d.tileX, tileY = d.tileY, tileW = d.tileW, tileH = d.tileH;
var canvasW = d.canvasW, canvasH = d.canvasH;
var centerRe = d.centerRe, centerIm = d.centerIm, magnification = d.magnification;
var maxIter = d.maxIter, paletteKey = d.paletteKey, colorOffset = d.colorOffset;
var setMode = d.setMode, juliaCre = d.juliaCre || 0, juliaCim = d.juliaCim || 0;
// Precompute view dimensions
var aspect = canvasW / canvasH;
var viewHeight = 3.0 / magnification;
var viewWidth = viewHeight * aspect;
var dRe = viewWidth / canvasW;
var dIm = viewHeight / canvasH;
// Starting complex coordinate for this tile (top-left, y goes down)
var startRe = centerRe + (tileX / canvasW - 0.5) * viewWidth;
var startIm = centerIm + ((tileY + tileH) / canvasH - 0.5) * viewHeight;
var data = new Uint8ClampedArray(tileW * tileH * 4);
var pal = PALETTE_DATA[paletteKey];
for (var ty = 0; ty < tileH; ty++) {
var im = startIm - ty * dIm;
for (var tx = 0; tx < tileW; tx++) {
var re = startRe + tx * dRe;
var iterVal = maxIter + 1;
if (setMode === 'julia') {
var zr = re, zi = im;
var zr2 = zr * zr, zi2 = zi * zi;
for (var n = 0; n < maxIter; n++) {
zi = 2 * zr * zi + juliaCim;
zr = zr2 - zi2 + juliaCre;
zr2 = zr * zr;
zi2 = zi * zi;
if (zr2 + zi2 > 256) {
var log_zn = Math.log(zr2 + zi2) * 0.5;
iterVal = n + 1 - Math.log(log_zn) / Math.LN2;
break;
}
}
} else {
var zr = 0, zi = 0;
var zr2 = 0, zi2 = 0;
for (var n = 0; n < maxIter; n++) {
zi = 2 * zr * zi + im;
zr = zr2 - zi2 + re;
zr2 = zr * zr;
zi2 = zi * zi;
if (zr2 + zi2 > 256) {
var log_zn = Math.log(zr2 + zi2) * 0.5;
iterVal = n + 1 - Math.log(log_zn) / Math.LN2;
break;
}
}
}
var idx = (ty * tileW + tx) * 4;
if (iterVal > maxIter) {
data[idx] = 0; data[idx+1] = 0; data[idx+2] = 0; data[idx+3] = 255;
} else if (!pal) {
// Fallback: simple grayscale
var v = Math.min(255, Math.round(iterVal * 4));
data[idx] = v; data[idx+1] = v; data[idx+2] = v; data[idx+3] = 255;
} else {
// Smooth coloring: normalize to palette position
var t = (iterVal % 64) / 64.0;
var pos;
if (paletteKey === 'psychedelic') {
pos = ((t + colorOffset) % 1.0 + 1.0) % 1.0;
} else {
pos = Math.max(0, Math.min(1, t + colorOffset));
}
// Find surrounding stops
var lo = pal[0], hi = pal[pal.length - 1];
for (var i = 0; i < pal.length - 1; i++) {
if (pos >= pal[i][0] && pos <= pal[i+1][0]) {
lo = pal[i]; hi = pal[i+1]; break;
}
}
var range = hi[0] - lo[0];
var frac = range > 0 ? (pos - lo[0]) / range : 0;
data[idx] = Math.round(lo[1] + (hi[1] - lo[1]) * frac);
data[idx+1] = Math.round(lo[2] + (hi[2] - lo[2]) * frac);
data[idx+2] = Math.round(lo[3] + (hi[3] - lo[3]) * frac);
data[idx+3] = 255;
}
}
}
self.postMessage({ renderId: d.renderId, tileX, tileY, tileW, tileH, data: data.buffer }, [data.buffer]);
};
`;
// ── function: setupEvents ──
/**
* Setup all event listeners for the fractal explorer.
*/
function setupEvents(app) {
const canvas = app.canvas;
const zoomOverlay = document.getElementById('zoom-overlay');
const crosshair = document.getElementById('crosshair');
// ---- Mouse: drag to pan, click-drag rectangle to zoom ----
let mouseDownTime = 0;
let mouseDownX = 0;
let mouseDownY = 0;
let isPanning = false;
let isZoomSelecting = false;
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // only left button
e.preventDefault();
app.resetHudTimer();
mouseDownTime = Date.now();
mouseDownX = e.offsetX;
mouseDownY = e.offsetY;
isPanning = false;
isZoomSelecting = false;
// Track for Julia preview in Mandelbrot mode
if (app.setMode === 'mandelbrot' && app.juliaPreviewMode) {
const c = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
app._previewJuliaCre = c.re;
app._previewJuliaCim = c.im;
}
});
canvas.addEventListener('mousemove', (e) => {
if (!app.hudVisible && !isPanning && !isZoomSelecting) return;
const dx = e.offsetX - mouseDownX;
const dy = e.offsetY - mouseDownY;
const dist = Math.sqrt(dx * dx + dy * dy);
// Julia preview on hover (Mandelbrot mode only, not during drag)
if (app.setMode === 'mandelbrot' && app.juliaPreviewMode && !isPanning && !isZoomSelecting) {
const c = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
if (app._previewJuliaCre !== c.re || app._previewJuliaCim !== c.im) {
app._previewJuliaCre = c.re;
app._previewJuliaCim = c.im;
}
}
if (dist < CONFIG.panDragThreshold && Date.now() - mouseDownTime > 200) {
// Long press without much movement → start zoom selection
isZoomSelecting = true;
canvas.style.cursor = 'crosshair';
return;
}
if (dist >= CONFIG.panDragThreshold || isPanning) {
isPanning = true;
isZoomSelecting = false;
canvas.style.cursor = 'grabbing';
// Pan: convert pixel delta to complex plane delta
const aspect = canvas.width / canvas.height;
const viewHeight = 3.0 / app.magnification;
const dRe = -dx / canvas.width * viewHeight * aspect;
const dIm = dy / canvas.height * viewHeight;
app.centerRe += dRe;
app.centerIm += dIm;
}
if (isZoomSelecting) {
// Update zoom overlay rectangle
const x1 = Math.min(mouseDownX, e.offsetX);
const y1 = Math.min(mouseDownY, e.offsetY);
const w = Math.abs(e.offsetX - mouseDownX);
const h = Math.abs(e.offsetY - mouseDownY);
zoomOverlay.style.display = 'block';
zoomOverlay.style.left = x1 + 'px';
zoomOverlay.style.top = y1 + 'px';
zoomOverlay.style.width = w + 'px';
zoomOverlay.style.height = h + 'px';
}
app.render();
});
canvas.addEventListener('mouseup', (e) => {
if (isZoomSelecting && Math.abs(e.offsetX - mouseDownX) > CONFIG.dragZoomMinPixels) {
// Zoom into the selected rectangle
const c1 = pixelToComplex(mouseDownX, mouseDownY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
const c2 = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
// Save current view for back button
app.zoomHistory.push({
centerRe: app.centerRe,
centerIm: app.centerIm,
magnification: app.magnification,
});
const newCenterRe = (c1.re + c2.re) / 2;
const newCenterIm = (c1.im + c2.im) / 2;
const width = Math.abs(c2.re - c1.re);
const height = Math.abs(c2.im - c1.im);
// Compute new magnification based on the larger dimension of the selection
const aspect = canvas.width / canvas.height;
let newMag;
if (width / height > aspect) {
newMag = app.magnification * ((3.0 / app.magnification) * aspect / width);
} else {
newMag = app.magnification * ((3.0 / app.magnification) / height);
}
app.centerRe = newCenterRe;
app.centerIm = newCenterIm;
app.magnification = Math.min(newMag, CONFIG.maxMagnification);
} else if (isPanning && app.setMode === 'mandelbrot') {
// Single click in Mandelbrot mode → enter Julia mode for that point
const c = pixelToComplex(e.offsetX, e.offsetY, canvas.width, canvas.height, app.centerRe, app.centerIm, app.magnification);
app.enterJuliaMode(c.re, c.im);
}
isPanning = false;
isZoomSelecting = false;
zoomOverlay.style.display = 'none';
canvas.style.cursor = 'crosshair';
updateCrosshair(app);
app.render();
});
// ---- Mouse wheel: zoom toward cursor ----
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
app.resetHudTimer();
const factor = Math.exp(-e.deltaY * CONFIG.zoomSpeed / 100);
app.zoomAt(e.offsetX, e.offsetY, factor);
}, { passive: false });
// ---- Keyboard controls ----
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return; // don't intercept typing in inputs
const panAmount = 0.1 / app.magnification;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
app.centerRe -= panAmount;
app.render();
break;
case 'ArrowRight':
e.preventDefault();
app.centerRe += panAmount;
app.render();
break;
case 'ArrowUp':
e.preventDefault();
app.centerIm -= panAmount;
app.render();
break;
case 'ArrowDown':
e.preventDefault();
app.centerIm += panAmount;
app.render();
break;
case '+':
case '=':
e.preventDefault();
app.zoomAt(canvas.width / 2, canvas.height / 2, 1.5);
break;
case '-':
case '_':
e.preventDefault();
app.zoomAt(canvas.width / 2, canvas.height / 2, 1 / 1.5);
break;
case 'z':
case 'Z':
e.preventDefault();
app.goBack();
break;
case 'r':
case 'R':
e.preventDefault();
app.resetView();
buildPaletteBar(app);
document.getElementById('iter-slider').value = CONFIG.baseIterations;
updateCrosshair(app);
break;
case 'j':
case 'J':
e.preventDefault();
if (app.setMode === 'mandelbrot') {
app.enterJuliaMode(app.juliaCre, app.juliaCim);
} else {
// Return to Mandelbrot view at the c-value location
app.zoomHistory.push({
centerRe: app.centerRe,
centerIm: app.centerIm,
magnification: app.magnification,
});
app.setMode = 'mandelbrot';
app.centerRe = app.juliaCre;
app.centerIm = app.juliaCim;
app.render();
}
buildPaletteBar(app);
updateCrosshair(app);
break;
case 'b':
case 'B':
e.preventDefault();
toggleBookmarkPanel(app);
break;
}
app.resetHudTimer();
});
// ---- HUD buttons ----
document.getElementById('btn-reset').addEventListener('click', () => {
app.resetView();
buildPaletteBar(app);
const iterSlider = document.getElementById('iter-slider');
if (iterSlider) iterSlider.value = CONFIG.baseIterations;
updateCrosshair(app);
});
document.getElementById('btn-back').addEventListener('click', () => {
app.goBack();
});
document.getElementById('btn-julia').addEventListener('click', () => {
if (app.setMode === 'mandelbrot') {
app.enterJuliaMode(app.juliaCre, app.juliaCim);
} else {
app.zoomHistory.push({
centerRe: app.centerRe,
centerIm: app.centerIm,
magnification: app.magnification,
});
app.setMode = 'mandelbrot';
app.centerRe = app.juliaCre;
app.centerIm = app.juliaCim;
app.render();
}
buildPaletteBar(app);
updateCrosshair(app);
});
// ---- Sliders ----
const iterSlider = document.getElementById('iter-slider');
if (iterSlider) {
iterSlider.addEventListener('input', () => {
app.maxIterOverride = parseInt(iterSlider.value);
app.render();
});
}
const colorOffsetSlider = document.getElementById('color-offset');
if (colorOffsetSlider) {
colorOffsetSlider.addEventListener('input', () => {
app.colorOffset = parseFloat(colorOffsetSlider.value);
app.render();
});
}
// ---- Bookmark panel ----
document.getElementById('btn-save-bookmark').addEventListener('click', () => {
const nameInput = document.getElementById('bookmark-name');
const name = nameInput.value.trim() || null;
app.saveBookmark(name);
nameInput.value = '';
renderBookmarkList(app);
});
document.getElementById('btn-close-bookmarks').addEventListener('click', () => {
toggleBookmarkPanel(app);
});
// ---- Mouse move for HUD visibility ----
canvas.addEventListener('mousemove', () => app.resetHudTimer());
// Initial crosshair state
updateCrosshair(app);
}
// ── function: updateCrosshair ──
/**
* Show/hide the crosshair based on current mode.
*/
function updateCrosshair(app) {
const crosshair = document.getElementById('crosshair');
if (!crosshair) return;
if (app.setMode === 'julia') {
crosshair.classList.remove('hidden');
} else {
crosshair.classList.add('hidden');
}
}
// ── class: FractalApp ──
// ============================================================
// APPLICATION STATE & NAVIGATION
// ============================================================
class FractalApp {
constructor(canvas) {
this.canvas = canvas;
this.renderer = new FractalRenderer(canvas);
// View state
this.centerRe = -0.5;
this.centerIm = 0;
this.magnification = 1;
this.maxIterOverride = null;
this.paletteKey = 'fire';
this.colorOffset = 0;
this.setMode = 'mandelbrot'; // 'mandelbrot' or 'julia'
this.juliaCre = -0.7269;
this.juliaCim = 0.1889;
// Navigation state
this.zoomHistory = [];
this.isDragging = false;
this.dragStartX = 0;
this.dragStartY = 0;
this.dragMode = null; // 'pan' or 'zoom'
this._lastMouseX = 0;
this._lastMouseY = 0;
// FPS tracking
this._frameTimes = [];
this._fps = 60;
// HUD auto-hide
this._hudTimer = null;
this.hudVisible = true;
// Julia preview mode
this.juliaPreviewMode = false;
// Setup renderer callback
this.renderer.onRenderComplete = () => {
if (this.onRenderComplete) this.onRenderComplete();
};
// Resize handler
this._resizeObserver = new ResizeObserver(() => this.resize());
this._resizeObserver.observe(this.canvas.parentElement || document.body);
}
resize() {
const parent = this.canvas.parentElement;
if (!parent) return;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
this.canvas.width = parent.clientWidth * dpr;
this.canvas.height = parent.clientHeight * dpr;
this._needsRender = true;
this.render();
}
getEffectiveMaxIter() {
return computeIterations(this.magnification, this.maxIterOverride);
}
render() {
const maxIter = this.getEffectiveMaxIter();
this.renderer.startRender(
this.centerRe, this.centerIm,
Math.min(this.magnification, CONFIG.maxMagnification),
maxIter,
this.paletteKey,
this.colorOffset,
this.setMode,
this.juliaCre,
this.juliaCim
);
}
setView({ re, im, scale }) {
if (re !== undefined) this.centerRe = re;
if (im !== undefined) this.centerIm = im;
if (scale !== undefined) this.magnification = Math.min(scale, CONFIG.maxMagnification);
this.render();
}
zoomToRegion(re1, im1, re2, im2) {
this.zoomHistory.push({
centerRe: this.centerRe,
centerIm: this.centerIm,
magnification: this.magnification,
});
const newCenterRe = (re1 + re2) / 2;
const newCenterIm = (im1 + im2) / 2;
const width = Math.abs(re2 - re1);
const height = Math.abs(im2 - im1);
const aspect = this.canvas.width / this.canvas.height;
let newMag;
if (width / height > aspect) {
newMag = this.magnification * ((3.0 / this.magnification) * aspect / width);
} else {
newMag = this.magnification * ((3.0 / this.magnification) / height);
}
this.centerRe = newCenterRe;
this.centerIm = newCenterIm;
this.magnification = Math.min(newMag, CONFIG.maxMagnification);
this.render();
}
zoomAt(px, py, factor) {
const c = pixelToComplex(px, py, this.canvas.width, this.canvas.height, this.centerRe, this.centerIm, this.magnification);
if (Math.abs(factor - 1.0) > 0.05) {
this.zoomHistory.push({
centerRe: this.centerRe,
centerIm: this.centerIm,
magnification: this.magnification,
});
}
const newMag = Math.min(this.magnification * factor, CONFIG.maxMagnification);
const aspect = this.canvas.width / this.canvas.height;
const viewHeightOld = 3.0 / this.magnification;
const viewWidthOld = viewHeightOld * aspect;
const viewHeightNew = 3.0 / newMag;
const viewWidthNew = viewHeightNew * aspect;
const fracX = (c.re - this.centerRe) / viewWidthOld;
const fracY = (c.im - this.centerIm) / viewHeightOld;
this.centerRe = c.re - fracX * viewWidthNew;
this.centerIm = c.im - fracY * viewHeightNew;
this.magnification = newMag;
this.render();
}
panBy(dRe, dIm) {
this.centerRe += dRe;
this.centerIm += dIm;
this.render();
}
goBack() {
if (this.zoomHistory.length === 0) return false;
const prev = this.zoomHistory.pop();
this.centerRe = prev.centerRe;
this.centerIm = prev.centerIm;
this.magnification = prev.magnification;
this.render();
return true;
}
resetView() {
this.zoomHistory.push({
centerRe: this.centerRe,
centerIm: this.centerIm,
magnification: this.magnification,
});
this.centerRe = -0.5;
this.centerIm = 0;
this.magnification = 1;
this.setMode = 'mandelbrot';
this.render();
}
enterJuliaMode(cre, cim) {
this.zoomHistory.push({
centerRe: this.centerRe,
centerIm: this.centerIm,
magnification: this.magnification,
});
this.setMode = 'julia';
this.juliaCre = cre;
this.juliaCim = cim;
this.centerRe = 0;
this.centerIm = 0;
this.magnification = Math.max(1, this.magnification * 0.5);
this.render();
}
iterationsAt(re, im) {
const maxIter = this.getEffectiveMaxIter();
return mandelbrotIteration(re, im, maxIter);
}
updateHUD() {
// Magnification with precision warning
const magEl = document.getElementById('mag-display');
if (magEl) {
const m = this.magnification;
let text;
if (m >= 1e6) text = `×${m.toExponential(2)}`;
else if (m >= 100) text = `×${Math.round(m).toLocaleString()}`;
else text = `×${m.toFixed(1)}`;
// Warn near precision limit
if (m > CONFIG.maxMagnification * 0.5) {
text += ' ⚠';
}
magEl.textContent = text;
}
// Center coordinates with adaptive precision
const centerEl = document.getElementById('center-display');
if (centerEl) {
const prec = Math.min(12, Math.max(6, Math.floor(Math.log10(this.magnification)) + 2));
centerEl.textContent = `(${this.centerRe.toFixed(prec)}, ${this.centerIm.toFixed(prec)})`;
}
// Iterations
const iterEl = document.getElementById('iter-display');
if (iterEl) {
iterEl.textContent = `Iter: ${this.getEffectiveMaxIter()}`;
}
// FPS
const fpsEl = document.getElementById('fps-display');
if (fpsEl) {
fpsEl.textContent = `${Math.round(this._fps)} FPS`;
}
// Mode display
const modeEl = document.getElementById('mode-display');
if (modeEl) {
if (this.setMode === 'julia') {
modeEl.textContent = `Julia c=${this.juliaCre.toFixed(4)}+${this.juliaCim.toFixed(4)}i`;
modeEl.style.color = '#f9a';
} else {
modeEl.textContent = 'Mandelbrot';
modeEl.style.color = '#7df';
}
}
// Julia mode button state
const juliaBtn = document.getElementById('btn-julia');
if (juliaBtn) {
juliaBtn.classList.toggle('active', this.setMode === 'julia');
}
// Back button visibility
const backBtn = document.getElementById('btn-back');
if (backBtn) {
backBtn.classList.toggle('hidden', this.zoomHistory.length === 0);
}
// Julia preview label
const juliaLabel = document.getElementById('julia-preview-label');
if (juliaLabel) {
juliaLabel.classList.toggle('hidden', !this.juliaPreviewMode);
const cDisplay = document.getElementById('julia-c-display');
if (cDisplay && this.juliaPreviewMode) {
cDisplay.textContent = `${this._previewJuliaCre.toFixed(6)} + ${this._previewJuliaCim.toFixed(6)}i`;
}
}
}
saveBookmark(name) {
const bookmarks = this.loadBookmarks();
const state = {
name: name || `View ${bookmarks.length + 1}`,
centerRe: this.centerRe,
centerIm: this.centerIm,
magnification: this.magnification,
maxIterOverride: this.maxIterOverride,
paletteKey: this.paletteKey,
colorOffset: this.colorOffset,
setMode: this.setMode,
juliaCre: this.juliaCre,
juliaCim: this.juliaCim,
};
bookmarks.push(state);
try { localStorage.setItem('fractal_bookmarks', JSON.stringify(bookmarks)); } catch(e) {}
return state;
}
loadBookmark(index) {
const bookmarks = this.loadBookmarks();
if (index < 0 || index >= bookmarks.length) return null;
const b = bookmarks[index];
this.centerRe = b.centerRe;
this.centerIm = b.centerIm;
this.magnification = Math.min(b.magnification, CONFIG.maxMagnification);
this.maxIterOverride = b.maxIterOverride || null;
this.paletteKey = b.paletteKey || 'fire';
this.colorOffset = b.colorOffset || 0;
this.setMode = b.setMode || 'mandelbrot';
this.juliaCre = b.juliaCre || -0.7269;
this.juliaCim = b.juliaCim || 0.1889;
this.render();
return b;
}
loadBookmarks() {
try {
const data = localStorage.getItem('fractal_bookmarks');
return data ? JSON.parse(data) : [];
} catch(e) { return []; }
}
deleteBookmark(index) {
const bookmarks = this.loadBookmarks();
if (index >= 0 && index < bookmarks.length) {
bookmarks.splice(index, 1);
try { localStorage.setItem('fractal_bookmarks', JSON.stringify(bookmarks)); } catch(e) {}
}
}
saveToHash() {
const state = {
re: this.centerRe, im: this.centerIm, m: this.magnification,
p: this.paletteKey, c: this.colorOffset, s: this.setMode,
jc: this.juliaCre, ji: this.juliaCim, i: this.maxIterOverride,
};
const encoded = btoa(JSON.stringify(state));
window.location.hash = encoded;
}
loadFromHash() {
if (!window.location.hash || window.location.hash.length < 2) return false;
try {
const decoded = JSON.parse(atob(window.location.hash.substring(1)));
this.centerRe = decoded.re;
this.centerIm = decoded.im;
this.magnification = Math.min(decoded.m, CONFIG.maxMagnification);
this.paletteKey = decoded.p || 'fire';
this.colorOffset = decoded.c || 0;
this.setMode = decoded.s || 'mandelbrot';
this.juliaCre = decoded.jc || -0.7269;
this.juliaCim = decoded.ji || 0.1889;
this.maxIterOverride = decoded.i !== undefined ? decoded.i : null;
return true;
} catch(e) { return false; }
}
trackFrame() {
const now = performance.now();
this._frameTimes.push(now);
while (this._frameTimes.length > CONFIG.fpsWindowFrames) {
this._frameTimes.shift();
}
if (this._frameTimes.length >= 2) {
const elapsed = now - this._frameTimes[0];
this._fps = (this._frameTimes.length - 1) / (elapsed / 1000);
}
}
resetHudTimer() {
if (this._hudTimer) clearTimeout(this._hudTimer);
const hud = document.getElementById('hud');
if (hud) hud.classList.remove('hidden-hud');
this.hudVisible = true;
this._hudTimer = setTimeout(() => {
if (!this.isDragging) {
if (hud) hud.classList.add('hidden-hud');
this.hudVisible = false;
}
}, CONFIG.hudHideDelay);
}
destroy() {
this.renderer.destroy();
if (this._resizeObserver) this._resizeObserver.disconnect();
if (this._hudTimer) clearTimeout(this._hudTimer);
}
}
// ── main ──
// ============================================================
// MAIN — Initialization & Debug Interface
// ============================================================
(function() {
const canvas = document.getElementById('fractal-canvas');
if (!canvas) { console.error('Canvas not found!'); return; }
// Create the app instance
const app = new FractalApp(canvas);
// Load state from URL hash if present, otherwise use defaults
const loadedFromHash = app.loadFromHash();
// Initial resize and render
app.resize();
// Setup UI
buildPaletteBar(app);
setupEvents(app);
// Update iteration slider to match current effective iterations
const iterSlider = document.getElementById('iter-slider');
if (iterSlider) {
iterSlider.value = app.maxIterOverride || CONFIG.baseIterations;
}
// Color offset slider
const colorOffsetSlider = document.getElementById('color-offset');
if (colorOffsetSlider) {
colorOffsetSlider.value = app.colorOffset;
}
// Update HUD periodically
function updateHUDLoop() {
app.updateHUD();
app.trackFrame();
requestAnimationFrame(updateHUDLoop);
}
requestAnimationFrame(updateHUDLoop);
// Expose debug interface on window
window.fractal = {
/** Get iteration count at a Mandelbrot point */
iterationsAt: function(re, im) {
return app.iterationsAt(re, im);
},
/** Set view parameters directly */
setView: function(params) {
app.setView(params);
},
/** Get current view state */
getViewState: function() {
return {
centerRe: app.centerRe,
centerIm: app.centerIm,
magnification: app.magnification,
maxIter: app.getEffectiveMaxIter(),
paletteKey: app.paletteKey,
colorOffset: app.colorOffset,
setMode: app.setMode,
juliaCre: app.juliaCre,
juliaCim: app.juliaCim,
};
},
/** Get the raw renderer */
getRenderer: function() { return app.renderer; },
/** Get the canvas element */
getCanvas: function() { return canvas; },
/** Full app instance for advanced debugging */
_app: app,
};
// Log debug info
console.log('%c Fractal Explorer Ready ', 'background:#333;color:#0f0;padding:4px 8px;border-radius:4px;font-weight:bold');
console.log('Debug interface available at window.fractal');
console.log('Try: fractal.iterationsAt(0, 0) → should be maxIter+1 (interior)');
console.log(' fractal.iterationsAt(-1, 0) → should be maxIter+1 (interior)');
console.log(' fractal.iterationsAt(2, 0) → should escape in ≤3 iterations');
console.log(' fractal.setView({re: -0.75, im: 0.1, scale: 1000})');
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"composer","sessionId":"743626f3-1831-4335-8234-7544da86b04e","tokensIn":1881234,"tokensOut":40915,"tokensTotal":1922149,"turns":52,"toolCalls":51,"failedToolCalls":0,"timestamp":"2026-07-30T03:39:20.255Z"} -->