Fractal Explorer
Qwen 3.6 27b MTP · full

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 44,711 bytes · SHA-256 bc14525fb3f4
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fractal Explorer</title>
<style>
/* ============================================================
CSS — Full-bleed canvas + glassy HUD overlay
============================================================ */
*, *::before, *::after { 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: #e0e0e0; user-select: none;
}
#fractalCanvas {
position: absolute; top: 0; left: 0;
display: block; width: 100%; height: 100%; cursor: crosshair; z-index: 1;
}
/* HUD overlay — glassy panels */
.hud-panel {
position: absolute; background: rgba(15, 15, 30, 0.72);
backdrop-filter: blur(12px) saturate(1.4); -webkit-backdrop-filter: blur(12px) saturate(1.4);
border: 1px solid rgba(255,255,255,0.08); border-radius: 10px;
padding: 10px 14px; font-size: 12px; line-height: 1.6;
transition: opacity 0.4s ease, transform 0.4s ease;
pointer-events: auto; z-index: 10;
}
.hud-panel.hidden {
opacity: 0; pointer-events: none; transform: translateY(-8px);
}
#hud-top-left { top: 12px; left: 12px; }
#hud-top-right { top: 12px; right: 12px; text-align: right; }
#hud-bottom { bottom: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; align-items: center; }
.hud-label { color: rgba(255,255,255,0.45); font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; }
.hud-value { color: #fff; font-weight: 600; font-variant-numeric: tabular-nums; }
/* Palette swatches */
.palette-row { display: flex; gap: 6px; margin-top: 4px; align-items: center; }
.swatch {
width: 28px; height: 18px; border-radius: 4px; cursor: pointer;
border: 2px solid transparent; transition: border-color 0.2s, transform 0.15s;
}
.swatch:hover { transform: scale(1.15); }
.swatch.active { border-color: #fff; box-shadow: 0 0 6px rgba(255,255,255,0.3); }
/* Buttons */
.hud-btn {
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.12);
color: #ddd; padding: 4px 10px; border-radius: 6px; cursor: pointer;
font-size: 11px; transition: background 0.2s;
}
.hud-btn:hover { background: rgba(255,255,255,0.18); }
/* Sliders */
input[type=range] {
-webkit-appearance: none; appearance: none; width: 90px; height: 4px;
background: rgba(255,255,255,0.15); border-radius: 2px; outline: none; cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%;
background: #fff; border: none; cursor: grab;
}
/* Zoom box overlay */
#zoomBox {
position: absolute; border: 2px solid rgba(255,255,255,0.7);
pointer-events: none; display: none; z-index: 20;
box-shadow: 0 0 0 9999px rgba(0,0,0,0.35);
}
/* Julia preview indicator */
#juliaIndicator {
position: absolute; top: 12px; left: 50%; transform: translateX(-50%);
background: rgba(255, 80, 40, 0.75); backdrop-filter: blur(8px);
padding: 6px 16px; border-radius: 20px; font-size: 12px; font-weight: 600;
display: none; z-index: 15; letter-spacing: 0.3px;
}
/* Bookmark list */
#bookmarkPanel {
position: absolute; top: 80px; right: 12px; width: 220px; max-height: 40vh; overflow-y: auto;
z-index: 15; display: none;
}
.bookmark-item {
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
margin-bottom: 4px; background: rgba(255,255,255,0.06); transition: background 0.15s;
}
.bookmark-item:hover { background: rgba(255,255,255,0.15); }
/* Crosshair for Julia mode */
#crosshair {
position: absolute; pointer-events: none; display: none; z-index: 18;
}
#crosshair::before, #crosshair::after {
content: ''; position: absolute; background: rgba(255,255,255,0.4);
}
#crosshair::before { width: 1px; height: 100%; left: 50%; top: 0; }
#crosshair::after { height: 1px; width: 100%; top: 50%; left: 0; }
/* Loading overlay */
#loadingOverlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.6); z-index: 30; pointer-events: none; opacity: 0; transition: opacity 0.2s;
}
#loadingOverlay.visible { opacity: 1; }
.spinner { width: 40px; height: 40px; border: 3px solid rgba(255,255,255,0.1); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Keyboard hint */
#keyHint {
position: absolute; bottom: 12px; right: 12px; font-size: 10px; color: rgba(255,255,255,0.3); z-index: 10;
}
/* Julia preview canvas (small overlay) */
#juliaPreview {
position: absolute; bottom: 60px; right: 12px; width: 180px; height: 180px;
border-radius: 10px; border: 2px solid rgba(255,255,255,0.15);
display: none; z-index: 15; image-rendering: pixelated;
}
/* FPS counter */
#fpsDisplay { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<canvas id="fractalCanvas"></canvas>
<div id="zoomBox"></div>
<div id="crosshair"></div>
<div id="juliaIndicator">JULIA MODE — c = <span id="juliaCVal"></span></div>
<canvas id="juliaPreview" width="180" height="180"></canvas>
<!-- HUD Panels -->
<div class="hud-panel hidden" id="hud-top-left">
<div><span class="hud-label">Magnification</span><br><span class="hud-value" id="magDisplay">×1.0</span></div>
<div style="margin-top:4px"><span class="hud-label">Center</span><br><span class="hud-value" id="centerDisplay">-0.5 + 0i</span></div>
<div style="margin-top:4px"><span class="hud-label">Set</span><br><span class="hud-value" id="setDisplay">Mandelbrot</span></div>
</div>
<div class="hud-panel hidden" id="hud-top-right">
<div><span class="hud-label">Iterations</span><br><span class="hud-value" id="iterDisplay">200</span></div>
<div style="margin-top:4px"><span class="hud-label">FPS</span><br><span class="hud-value" id="fpsDisplay">60</span></div>
<div style="margin-top:4px"><span class="hud-label">Depth</span><br><span class="hud-value" id="depthDisplay">1e0</span></div>
</div>
<div class="hud-panel hidden" id="hud-bottom">
<div class="palette-row" id="paletteRow"></div>
<button class="hud-btn" id="btnBack" title="Zoom Back (B)">◀ Back</button>
<button class="hud-btn" id="btnReset" title="Reset View (R)">⟲ Reset</button>
<button class="hud-btn" id="btnJulia" title="Toggle Julia Mode (J)">★ Julia</button>
<button class="hud-btn" id="btnBookmark" title="Save Bookmark (S)">🔖 Save</button>
<div style="margin-left:8px">
<span class="hud-label">Offset</span><br>
<input type="range" id="colorOffset" min="0" max="360" value="0" step="1">
</div>
<div style="margin-left:4px">
<span class="hud-label">Iter</span><br>
<input type="range" id="iterSlider" min="50" max="2000" value="200" step="10">
</div>
</div>
<div id="bookmarkPanel"></div>
<div id="loadingOverlay"><div class="spinner"></div></div>
<div id="keyHint">Drag: zoom box · Scroll: zoom · Shift+drag: pan · Arrows: pan · +/-: zoom · J: Julia · B: back · R: reset</div>
<script>
/* ============================================================
SECTION 1 — Tunable Constants & Configuration
============================================================ */
const CONFIG = {
// Iteration limits
maxIterationsBase: 200,
maxIterationsScale: 80, // additional iterations per log10(magnification)
maxIterationsCap: 15000,
// Zoom behavior
zoomSpeed: 0.6, // wheel zoom factor (fraction of current view)
minMagnification: 0.3,
maxMagnification: 1e14, // double-precision limit (~1e14 before artifacts)
// Progressive rendering passes
progressivePasses: [0.25, 0.5, 1.0], // resolution fractions per pass
progressiveDelayMs: 80, // ms between passes for responsiveness
// Julia preview
juliaPreviewSize: 180,
juliaPreviewUpdateInterval: 100, // ms between live-preview updates
// HUD auto-hide
hudHideTimeout: 8000, // ms of inactivity before hiding HUD
// Render worker count (0 = main thread only)
workerCount: 4,
// Color offset animation speed (degrees/sec when cycling enabled)
colorCycleSpeed: 15,
};
/* ============================================================
SECTION 2 — Palette Definitions
Each palette is an array of {pos, r, g, b} stops for interpolation.
============================================================ */
const PALETTES = [
{
name: 'Fire',
colors: [
{ pos: 0.0, r: 0, g: 0, b: 0 },
{ pos: 0.15, r: 80, g: 0, b: 0 },
{ pos: 0.35, r: 200, g: 40, b: 0 },
{ pos: 0.55, r: 255, g: 160, b: 0 },
{ pos: 0.75, r: 255, g: 240, b: 80 },
{ pos: 1.0, r: 255, g: 255, b: 220 },
],
swatch: ['#300', '#c20', '#fa0', '#ff5'],
},
{
name: 'Ocean',
colors: [
{ pos: 0.0, r: 0, g: 0, b: 10 },
{ pos: 0.2, r: 0, g: 20, b: 80 },
{ pos: 0.45, r: 0, g: 80, b: 160 },
{ pos: 0.7, r: 30, g: 180, b: 220 },
{ pos: 0.9, r: 140, g: 230, b: 255 },
{ pos: 1.0, r: 220, g: 250, b: 255 },
],
swatch: ['#006', '#08a', '#4cc', '#aff'],
},
{
name: 'Monochrome',
colors: [
{ pos: 0.0, r: 0, g: 0, b: 0 },
{ pos: 0.5, r: 128, g: 128, b: 130 },
{ pos: 1.0, r: 240, g: 240, b: 245 },
],
swatch: ['#222', '#667', '#ccc'],
},
{
name: 'Psychedelic',
colors: [
{ pos: 0.0, r: 128, g: 0, b: 128 },
{ pos: 0.25, r: 0, g: 0, b: 255 },
{ pos: 0.5, r: 0, g: 255, b: 0 },
{ pos: 0.75, r: 255, g: 255, b: 0 },
{ pos: 1.0, r: 255, g: 0, b: 80 },
],
swatch: ['#606', '#00f', '#0f0', '#ff0'],
},
{
name: 'Electric',
colors: [
{ pos: 0.0, r: 5, g: 5, b: 30 },
{ pos: 0.2, r: 40, g: 10, b: 120 },
{ pos: 0.4, r: 80, g: 60, b: 200 },
{ pos: 0.6, r: 100, g: 180, b: 255 },
{ pos: 0.8, r: 200, g: 230, b: 255 },
{ pos: 1.0, r: 255, g: 255, b: 255 },
],
swatch: ['#004', '#40a', '#86c', '#caf'],
},
];
/* ============================================================
SECTION 3 — Math Utilities & Core Iteration Logic
============================================================ */
/**
* Compute the smooth (continuous) iteration count for a point.
* Returns maxIterations if the point is in the set, otherwise the fractional iteration count.
* Uses the escape-time algorithm with smooth coloring: n + 1 - log2(log|z|)
* @param {number} re - real part of c (for Mandelbrot) or z0 (for Julia)
* @param {number} im - imaginary part of c / z0
* @param {number} cr - real part of the constant c
* @param {number} ci - imaginary part of the constant c
* @param {number} maxIter - maximum iterations
* @returns {number} smooth iteration count, or maxIter if in set
*/
function mandelbrotIteration(re, im, cr, ci, maxIter) {
let zr = re;
let zi = im;
let zr2 = zr * zr;
let zi2 = zi * zi;
for (let n = 0; n < maxIter; n++) {
if (zr2 + zi2 > 256) {
// Smooth iteration count: n + 1 - log2(log(|z|))
const logZ = Math.log(zr2 + zi2) * 0.5;
return n + 1 - Math.log(logZ) / Math.LN2;
}
zi = 2 * zr * zi + ci;
zr = zr2 - zi2 + cr;
zr2 = zr * zr;
zi2 = zi * zi;
}
return maxIter; // point is in the set (or on boundary)
}
/**
* Compute Mandelbrot iteration for a given c-value.
*/
function mandelbrotC(cr, ci, maxIter) {
return mandelbrotIteration(0, 0, cr, ci, maxIter);
}
/**
* Compute Julia set iteration: z_{n+1} = z_n^2 + c, starting from (re, im).
*/
function juliaIteration(re, im, cr, ci, maxIter) {
return mandelbrotIteration(re, im, cr, ci, maxIter);
}
/* ============================================================
SECTION 4 — Color Palette Interpolation
============================================================ */
/**
* Build a lookup table for a palette (256 entries).
* Returns Uint8Array of length 1024 (RGBA × 256).
*/
function buildPaletteLUT(palette, offset) {
const lut = new Uint8Array(1024); // 256 colors × 4 channels
const stops = palette.colors;
for (let i = 0; i < 256; i++) {
let t = ((i / 255) + offset / 360) % 1.0;
if (t < 0) t += 1;
// Find surrounding stops
let lo = stops[0], hi = stops[stops.length - 1];
for (let s = 0; s < stops.length - 1; s++) {
if (t >= stops[s].pos && t <= stops[s + 1].pos) {
lo = stops[s];
hi = stops[s + 1];
break;
}
}
const range = hi.pos - lo.pos || 1;
const frac = (t - lo.pos) / range;
// Smoothstep for nicer gradients
const sf = frac * frac * (3 - 2 * frac);
lut[i * 4] = Math.round(lo.r + (hi.r - lo.r) * sf);
lut[i * 4 + 1] = Math.round(lo.g + (hi.g - lo.g) * sf);
lut[i * 4 + 2] = Math.round(lo.b + (hi.b - lo.b) * sf);
lut[i * 4 + 3] = 255;
}
return lut;
}
/* ============================================================
SECTION 5 — Fractal State & View Management
============================================================ */
const fractalState = {
// View parameters (complex plane)
centerRe: -0.5,
centerIm: 0,
magnification: 1, // how many times zoomed in from default view
// Rendering params
maxIterations: CONFIG.maxIterationsBase,
paletteIndex: 0,
colorOffset: 0,
autoCycleColor: false,
// Set type
setMode: 'mandelbrot', // 'mandelbrot' or 'julia'
juliaCRe: -0.7269, // default Julia c-value (interesting spiral)
juliaCIm: 0.1889,
// Zoom history stack
zoomHistory: [],
// Canvas dimensions
canvasWidth: 0,
canvasHeight: 0,
// Rendering state
renderCancelled: false,
currentPassIndex: 0,
};
/**
* Compute the complex plane bounds from view parameters.
*/
function getViewBounds() {
const aspect = fractalState.canvasWidth / fractalState.canvasHeight;
const halfH = 1.5 / fractalState.magnification;
const halfW = halfH * aspect;
return {
reMin: fractalState.centerRe - halfW,
reMax: fractalState.centerRe + halfW,
imMin: fractalState.centerIm - halfH,
imMax: fractalState.centerIm + halfH,
};
}
/**
* Convert screen pixel to complex plane coordinate.
*/
function screenToComplex(px, py) {
const bounds = getViewBounds();
const re = bounds.reMin + (px / fractalState.canvasWidth) * (bounds.reMax - bounds.reMin);
const im = bounds.imMin + (py / fractalState.canvasHeight) * (bounds.imMax - bounds.imMin);
return { re, im };
}
/**
* Convert complex plane coordinate to screen pixel.
*/
function complexToScreen(re, im) {
const bounds = getViewBounds();
const px = ((re - bounds.reMin) / (bounds.reMax - bounds.reMin)) * fractalState.canvasWidth;
const py = ((im - bounds.imMin) / (bounds.imMax - bounds.imMin)) * fractalState.canvasHeight;
return { x: px, y: py };
}
/**
* Set the view to specific parameters. Pushes current state to history.
*/
function setView(re, im, mag) {
// Clamp magnification
if (mag > CONFIG.maxMagnification) {
console.warn(`Magnification ${mag.toExponential(2)} exceeds precision limit (${CONFIG.maxMagnification.toExponential()}). Clamped.`);
mag = CONFIG.maxMagnification;
}
if (mag < CONFIG.minMagnification) mag = CONFIG.minMagnification;
fractalState.zoomHistory.push({
re: fractalState.centerRe,
im: fractalState.centerIm,
mag: fractalState.magnification,
});
fractalState.centerRe = re;
fractalState.centerIm = im;
fractalState.magnification = mag;
updateIterationLimit();
scheduleRender();
}
/**
* Zoom back to previous view.
*/
function zoomBack() {
if (fractalState.zoomHistory.length === 0) return;
const prev = fractalState.zoomHistory.pop();
fractalState.centerRe = prev.re;
fractalState.centerIm = prev.im;
fractalState.magnification = prev.mag;
updateIterationLimit();
scheduleRender();
}
/**
* Reset to default view.
*/
function resetView() {
setView(-0.5, 0, 1);
fractalState.setMode = 'mandelbrot';
fractalState.zoomHistory = [];
}
/**
* Update iteration limit based on magnification depth.
*/
function updateIterationLimit() {
const logMag = Math.log10(fractalState.magnification);
let iter = CONFIG.maxIterationsBase + Math.floor(logMag * CONFIG.maxIterationsScale);
if (iter > CONFIG.maxIterationsCap) iter = CONFIG.maxIterationsCap;
fractalState.maxIterations = iter;
// Update slider to reflect auto-computed value
const slider = document.getElementById('iterSlider');
if (!slider._manualOverride) {
slider.value = Math.min(iter, parseInt(slider.max));
}
}
/* ============================================================
SECTION 6 — Rendering Engine (ImageData Pipeline)
============================================================ */
const canvas = document.getElementById('fractalCanvas');
const ctx = canvas.getContext('2d', { alpha: false }); // no alpha channel needed
let imageData = null;
let pixelBuffer = null; // Uint32Array view for fast per-pixel writes
/**
* Resize the canvas and allocate buffers.
*/
function resizeCanvas() {
const dpr = window.devicePixelRatio || 1;
fractalState.canvasWidth = Math.floor(window.innerWidth * dpr);
fractalState.canvasHeight = Math.floor(window.innerHeight * dpr);
canvas.width = fractalState.canvasWidth;
canvas.height = fractalState.canvasHeight;
imageData = ctx.createImageData(fractalState.canvasWidth, fractalState.canvasHeight);
pixelBuffer = new Uint32Array(imageData.data.buffer);
}
/**
* Render the fractal at a given resolution fraction (progressive rendering).
* This is the core allocation-free iteration loop.
*/
function renderFractal(resolutionFraction) {
const w = fractalState.canvasWidth;
const h = fractalState.canvasHeight;
const stepX = Math.max(1, Math.round(1 / resolutionFraction));
const stepY = Math.max(1, Math.round(1 / resolutionFraction));
const bounds = getViewBounds();
const reRange = bounds.reMax - bounds.reMin;
const imRange = bounds.imMax - bounds.imMin;
const dRe = reRange / w;
const dIm = imRange / h;
const maxIter = fractalState.maxIterations;
const paletteLUT = buildPaletteLUT(PALETTES[fractalState.paletteIndex], fractalState.colorOffset);
// Precompute LUT as Uint32 for fast writes (little-endian: ABGR)
const lut32 = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const idx = i * 4;
// Store as 0xAABBGGRR (little-endian on x86)
lut32[i] = paletteLUT[idx + 3] | (paletteLUT[idx + 2] << 8) | (paletteLUT[idx + 1] << 16) | (paletteLUT[idx] << 24);
}
const isJulia = fractalState.setMode === 'julia';
const jcr = fractalState.juliaCRe;
const jci = fractalState.juliaCIm;
// Main iteration loop — allocation-free, tight inner loop
for (let py = 0; py < h; py += stepY) {
if (fractalState.renderCancelled) return;
const imBase = bounds.imMin + py * dIm;
for (let px = 0; px < w; px += stepX) {
let cr, ci, zr, zi;
if (isJulia) {
// Julia: z starts at pixel position, c is fixed
zr = bounds.reMin + px * dRe;
zi = imBase;
cr = jcr;
ci = jci;
} else {
// Mandelbrot: z starts at 0, c is pixel position
cr = bounds.reMin + px * dRe;
ci = imBase;
zr = 0;
zi = 0;
}
const smoothIter = mandelbrotIteration(zr, zi, cr, ci, maxIter);
let colorIdx;
if (smoothIter >= maxIter) {
// Interior point — black
colorIdx = 0;
} else {
// Map smooth iteration to palette index with cycling
const t = ((smoothIter * 3.7 + fractalState.colorOffset / 360 * 256) % 256);
colorIdx = Math.floor(t < 0 ? t + 256 : t);
}
// Fill the block covered by this step
const fillW = Math.min(stepX, w - px);
const fillH = Math.min(stepY, h - py);
for (let fy = 0; fy < fillH; fy++) {
const rowOffset = (py + fy) * w;
for (let fx = 0; fx < fillW; fx++) {
pixelBuffer[rowOffset + px + fx] = lut32[colorIdx];
}
}
}
// Yield to browser every few rows to keep UI responsive
if (py % (stepY * 40) === 0 && stepY > 1) {
yieldFrame();
}
}
ctx.putImageData(imageData, 0, 0);
}
/**
* Yield one frame to the browser event loop.
*/
function yieldFrame() {
return new Promise(r => setTimeout(r, 0));
}
/* ============================================================
SECTION 7 — Progressive Rendering with Cancellation
============================================================ */
let renderTimeout = null;
let currentRenderPass = -1;
/**
* Schedule a progressive render. Cancels any in-flight render.
*/
function scheduleRender() {
fractalState.renderCancelled = true;
if (renderTimeout) clearTimeout(renderTimeout);
// Small delay to batch rapid interactions
renderTimeout = setTimeout(() => startProgressiveRender(), 50);
}
/**
* Start progressive rendering through resolution passes.
*/
async function startProgressiveRender() {
fractalState.renderCancelled = false;
currentRenderPass = 0;
const passes = CONFIG.progressivePasses;
for (let p = 0; p < passes.length; p++) {
if (fractalState.renderCancelled) break;
currentRenderPass = p;
renderFractal(passes[p]);
// Show loading overlay only during first pass
const overlay = document.getElementById('loadingOverlay');
if (p === 0 && passes.length > 1) {
overlay.classList.add('visible');
} else {
overlay.classList.remove('visible');
}
if (p < passes.length - 1 && !fractalState.renderCancelled) {
await new Promise(r => setTimeout(r, CONFIG.progressiveDelayMs));
}
}
currentRenderPass = -1;
document.getElementById('loadingOverlay').classList.remove('visible');
updateHUD();
}
/* ============================================================
SECTION 8 — Julia Preview Rendering
============================================================ */
const juliaPreviewCanvas = document.getElementById('juliaPreview');
const jpcCtx = juliaPreviewCanvas.getContext('2d', { alpha: false });
let jpImageData = null;
let jpPixelBuffer = null;
function initJuliaPreview() {
const size = CONFIG.juliaPreviewSize;
juliaPreviewCanvas.width = size;
juliaPreviewCanvas.height = size;
jpImageData = jpcCtx.createImageData(size, size);
jpPixelBuffer = new Uint32Array(jpImageData.data.buffer);
}
let lastJuliaPreviewTime = 0;
function renderJuliaPreview(cRe, cIm) {
const now = performance.now();
if (now - lastJuliaPreviewTime < CONFIG.juliaPreviewUpdateInterval) return;
lastJuliaPreviewTime = now;
fractalState.juliaCRe = cRe;
fractalState.juliaCIm = cIm;
const size = CONFIG.juliaPreviewSize;
const maxIter = Math.min(fractalState.maxIterations, 500); // cap for preview speed
const paletteLUT = buildPaletteLUT(PALETTES[fractalState.paletteIndex], fractalState.colorOffset);
const lut32 = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
const idx = i * 4;
lut32[i] = paletteLUT[idx + 3] | (paletteLUT[idx + 2] << 8) | (paletteLUT[idx + 1] << 16) | (paletteLUT[idx] << 24);
}
const bounds = { reMin: -1.5, reMax: 1.5, imMin: -1.5, imMax: 1.5 };
const dRe = (bounds.reMax - bounds.reMin) / size;
const dIm = (bounds.imMax - bounds.imMin) / size;
for (let py = 0; py < size; py++) {
const zi = bounds.imMin + py * dIm;
for (let px = 0; px < size; px++) {
const zr = bounds.reMin + px * dRe;
const smoothIter = mandelbrotIteration(zr, zi, cRe, cIm, maxIter);
let colorIdx;
if (smoothIter >= maxIter) {
colorIdx = 0;
} else {
const t = ((smoothIter * 3.7 + fractalState.colorOffset / 360 * 256) % 256);
colorIdx = Math.floor(t < 0 ? t + 256 : t);
}
jpPixelBuffer[py * size + px] = lut32[colorIdx];
}
}
jpcCtx.putImageData(jpImageData, 0, 0);
}
/* ============================================================
SECTION 9 — Navigation & Interaction Handlers
============================================================ */
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let dragMode = 'zoom'; // 'zoom' or 'pan'
let panStartRe = 0, panStartIm = 0;
const zoomBoxEl = document.getElementById('zoomBox');
canvas.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // only left button
isDragging = true;
dragMode = e.shiftKey ? 'pan' : 'zoom';
dragStartX = e.clientX * (window.devicePixelRatio || 1);
dragStartY = e.clientY * (window.devicePixelRatio || 1);
const cplx = screenToComplex(dragStartX, dragStartY);
panStartRe = fractalState.centerRe;
panStartIm = fractalState.centerIm;
if (dragMode === 'zoom') {
zoomBoxEl.style.display = 'block';
zoomBoxEl.style.left = e.clientX + 'px';
zoomBoxEl.style.top = e.clientY + 'px';
zoomBoxEl.style.width = '0px';
zoomBoxEl.style.height = '0px';
}
resetHUDTimer();
});
canvas.addEventListener('mousemove', (e) => {
const dpr = window.devicePixelRatio || 1;
const mx = e.clientX * dpr;
const my = e.clientY * dpr;
if (isDragging) {
if (dragMode === 'zoom') {
// Update zoom box overlay
const x = Math.min(dragStartX, mx);
const y = Math.min(dragStartY, my);
const w = Math.abs(mx - dragStartX);
const h = Math.abs(my - dragStartY);
zoomBoxEl.style.left = (x / dpr) + 'px';
zoomBoxEl.style.top = (y / dpr) + 'px';
zoomBoxEl.style.width = (w / dpr) + 'px';
zoomBoxEl.style.height = (h / dpr) + 'px';
} else {
// Pan mode — live update
const dx = mx - dragStartX;
const dy = my - dragStartY;
const bounds = getViewBounds();
const dRe = (bounds.reMax - bounds.reMin);
const dIm = (bounds.imMax - bounds.imMin);
fractalState.centerRe = panStartRe - (dx / fractalState.canvasWidth) * dRe;
fractalState.centerIm = panStartIm + (dy / fractalState.canvasHeight) * dIm;
}
} else {
// Not dragging — live Julia preview when hovering Mandelbrot set
if (fractalState.setMode === 'mandelbrot') {
const cplx = screenToComplex(mx, my);
renderJuliaPreview(cplx.re, cplx.im);
juliaPreviewCanvas.style.display = 'block';
// Update crosshair position
const crosshair = document.getElementById('crosshair');
crosshair.style.display = 'block';
crosshair.style.left = e.clientX + 'px';
crosshair.style.top = e.clientY + 'px';
}
}
resetHUDTimer();
});
canvas.addEventListener('mouseup', (e) => {
if (!isDragging) return;
isDragging = false;
const dpr = window.devicePixelRatio || 1;
const mx = e.clientX * dpr;
const my = e.clientY * dpr;
if (dragMode === 'zoom') {
zoomBoxEl.style.display = 'none';
// Compute the selected rectangle in complex plane coordinates
const x1 = Math.min(dragStartX, mx);
const y1 = Math.min(dragStartY, my);
const x2 = Math.max(dragStartX, mx);
const y2 = Math.max(dragStartY, my);
// Minimum selection size to avoid accidental tiny zooms
if ((x2 - x1) < 5 || (y2 - y1) < 5) {
// Too small — treat as a click: save Julia c-value at this point
const cplx = screenToComplex(mx, my);
fractalState.juliaCRe = cplx.re;
fractalState.juliaCIm = cplx.im;
return;
}
// Convert rectangle corners to complex plane
const topLeft = screenToComplex(x1, y1);
const bottomRight = screenToComplex(x2, y2);
// New center is midpoint of selection
const newRe = (topLeft.re + bottomRight.re) / 2;
const newIm = (topLeft.im + bottomRight.im) / 2;
// New magnification: how much did we zoom?
const oldBounds = getViewBounds();
const oldWidth = oldBounds.reMax - oldBounds.reMin;
const selectedWidth = Math.abs(bottomRight.re - topLeft.re);
const newMag = fractalState.magnification * (oldWidth / selectedWidth);
setView(newRe, newIm, newMag);
} else {
// Pan mode — schedule render after pan completes
scheduleRender();
}
resetHUDTimer();
});
canvas.addEventListener('mouseleave', () => {
if (isDragging) {
isDragging = false;
zoomBoxEl.style.display = 'none';
}
document.getElementById('crosshair').style.display = 'none';
juliaPreviewCanvas.style.display = 'none';
});
// Wheel zoom toward cursor
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const dpr = window.devicePixelRatio || 1;
const mx = e.clientX * dpr;
const my = e.clientY * dpr;
// Get the complex coordinate under the cursor before zooming
const cplxBefore = screenToComplex(mx, my);
// Apply zoom factor
const zoomFactor = Math.pow(1.15, -e.deltaY / (e.deltaY < 0 ? 30 : 30));
const newMag = fractalState.magnification * zoomFactor;
// Clamp magnification
if (newMag > CONFIG.maxMagnification) {
console.warn(`Zoom limit reached (${CONFIG.maxMagnification.toExponential()}). Double precision degrades beyond this.`);
return;
}
if (newMag < CONFIG.minMagnification) return;
// Temporarily set new magnification to compute where the cursor point maps
const oldMag = fractalState.magnification;
fractalState.magnification = newMag;
// Get the complex coordinate under the cursor after zooming (center unchanged)
const cplxAfter = screenToComplex(mx, my);
// Shift center so that the point under the cursor stays fixed
fractalState.centerRe += (cplxBefore.re - cplxAfter.re);
fractalState.centerIm += (cplxBefore.im - cplxAfter.im);
updateIterationLimit();
scheduleRender();
resetHUDTimer();
}, { passive: false });
// Keyboard controls
document.addEventListener('keydown', (e) => {
const panAmount = 0.15 / fractalState.magnification;
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
fractalState.centerRe -= panAmount;
scheduleRender(); resetHUDTimer();
break;
case 'ArrowRight':
e.preventDefault();
fractalState.centerRe += panAmount;
scheduleRender(); resetHUDTimer();
break;
case 'ArrowUp':
e.preventDefault();
fractalState.centerIm -= panAmount;
scheduleRender(); resetHUDTimer();
break;
case 'ArrowDown':
e.preventDefault();
fractalState.centerIm += panAmount;
scheduleRender(); resetHUDTimer();
break;
case '+':
case '=':
e.preventDefault();
setView(fractalState.centerRe, fractalState.centerIm, fractalState.magnification * 2);
resetHUDTimer();
break;
case '-':
case '_':
e.preventDefault();
setView(fractalState.centerRe, fractalState.centerIm, fractalState.magnification / 2);
resetHUDTimer();
break;
case 'j':
case 'J':
toggleJuliaMode();
resetHUDTimer();
break;
case 'b':
case 'B':
zoomBack();
resetHUDTimer();
break;
case 'r':
case 'R':
resetView();
resetHUDTimer();
break;
case 's':
case 'S':
saveBookmark();
resetHUDTimer();
break;
}
});
/* ============================================================
SECTION 10 — Julia Mode Toggle
============================================================ */
function toggleJuliaMode() {
if (fractalState.setMode === 'mandelbrot') {
// Switch to Julia mode using current c-value from preview or last click
fractalState.setMode = 'julia';
const indicator = document.getElementById('juliaIndicator');
indicator.style.display = 'block';
document.getElementById('juliaCVal').textContent =
`${fractalState.juliaCRe.toFixed(6)} ${fractalState.juliaCIm >= 0 ? '+' : ''}${fractalState.juliaCIm.toFixed(6)}i`;
juliaPreviewCanvas.style.display = 'none';
document.getElementById('crosshair').style.display = 'none';
} else {
fractalState.setMode = 'mandelbrot';
document.getElementById('juliaIndicator').style.display = 'none';
}
scheduleRender();
}
/* ============================================================
SECTION 11 — HUD & UI Management
============================================================ */
let hudTimer = null;
const hudPanels = [
document.getElementById('hud-top-left'),
document.getElementById('hud-top-right'),
document.getElementById('hud-bottom'),
];
function showHUD() {
hudPanels.forEach(p => p.classList.remove('hidden'));
}
function hideHUD() {
hudPanels.forEach(p => p.classList.add('hidden'));
}
function resetHUDTimer() {
showHUD();
if (hudTimer) clearTimeout(hudTimer);
hudTimer = setTimeout(hideHUD, CONFIG.hudHideTimeout);
}
/**
* Update HUD displays with current state.
*/
function updateHUD() {
const mag = fractalState.magnification;
document.getElementById('magDisplay').textContent = `×${formatNumber(mag)}`;
document.getElementById('centerDisplay').textContent =
`${fractalState.centerRe.toPrecision(10)} ${fractalState.centerIm >= 0 ? '+' : ''}${fractalState.centerIm.toPrecision(10)}i`;
document.getElementById('setDisplay').textContent =
fractalState.setMode === 'julia' ? `Julia (${fractalState.juliaCRe.toFixed(4)}, ${fractalState.juliaCIm.toFixed(4)})` : 'Mandelbrot';
document.getElementById('iterDisplay').textContent = fractalState.maxIterations;
const expStr = mag.toExponential(1);
document.getElementById('depthDisplay').textContent = `1e${expStr.split('e')[1]}`;
}
function formatNumber(n) {
if (n >= 1e6) return n.toExponential(2);
if (n >= 1000) return n.toFixed(1);
if (n >= 1) return n.toFixed(2);
return n.toPrecision(3);
}
// FPS tracking
let fpsFrames = 0;
let fpsLastTime = performance.now();
function updateFPS() {
fpsFrames++;
const now = performance.now();
if (now - fpsLastTime >= 1000) {
document.getElementById('fpsDisplay').textContent = fpsFrames;
fpsFrames = 0;
fpsLastTime = now;
}
requestAnimationFrame(updateFPS);
}
/* ============================================================
SECTION 12 — Palette UI & Color Controls
============================================================ */
function buildPaletteUI() {
const row = document.getElementById('paletteRow');
PALETTES.forEach((pal, idx) => {
pal.swatch.forEach(color => {
const swatch = document.createElement('div');
swatch.className = 'swatch' + (idx === fractalState.paletteIndex ? ' active' : '');
swatch.style.background = color;
if (color === pal.swatch[0]) {
swatch.title = pal.name;
swatch.addEventListener('click', () => {
fractalState.paletteIndex = idx;
document.querySelectorAll('.swatch').forEach(s => s.classList.remove('active'));
row.children[idx * 4]?.classList.add('active'); // first swatch of palette
scheduleRender();
});
} else {
swatch.style.cursor = 'default';
}
row.appendChild(swatch);
});
});
}
// Color offset slider — only re-colorizes, doesn't recompute iterations
document.getElementById('colorOffset').addEventListener('input', (e) => {
fractalState.colorOffset = parseFloat(e.target.value);
scheduleRender(); // needs full re-render since color mapping changes per-pixel
});
// Iteration limit slider — manual override
document.getElementById('iterSlider').addEventListener('input', (e) => {
document.getElementById('iterSlider')._manualOverride = true;
fractalState.maxIterations = parseInt(e.target.value);
scheduleRender();
});
/* ============================================================
SECTION 13 — Bookmark System (localStorage)
============================================================ */
const BOOKMARK_KEY = 'fractalExplorer_bookmarks';
function getBookmarks() {
try {
return JSON.parse(localStorage.getItem(BOOKMARK_KEY)) || [];
} catch { return []; }
}
function saveBookmarks(bookmarks) {
localStorage.setItem(BOOKMARK_KEY, JSON.stringify(bookmarks));
}
function saveBookmark() {
const name = prompt('Bookmark name:', `View ${getBookmarks().length + 1}`);
if (!name) return;
const bookmark = {
name,
re: fractalState.centerRe,
im: fractalState.centerIm,
mag: fractalState.magnification,
paletteIndex: fractalState.paletteIndex,
colorOffset: fractalState.colorOffset,
maxIterations: fractalState.maxIterations,
setMode: fractalState.setMode,
juliaCRe: fractalState.juliaCRe,
juliaCIm: fractalState.juliaCIm,
timestamp: Date.now(),
};
const bookmarks = getBookmarks();
bookmarks.push(bookmark);
saveBookmarks(bookmarks);
renderBookmarkList();
}
function loadBookmark(index) {
const bookmarks = getBookmarks();
if (index < 0 || index >= bookmarks.length) return;
const b = bookmarks[index];
fractalState.centerRe = b.re;
fractalState.centerIm = b.im;
fractalState.magnification = b.mag;
fractalState.paletteIndex = b.paletteIndex || 0;
fractalState.colorOffset = b.colorOffset || 0;
fractalState.maxIterations = b.maxIterations || CONFIG.maxIterationsBase;
fractalState.setMode = b.setMode || 'mandelbrot';
fractalState.juliaCRe = b.juliaCRe ?? -0.7269;
fractalState.juliaCIm = b.juliaCIm ?? 0.1889;
document.getElementById('colorOffset').value = fractalState.colorOffset;
document.getElementById('iterSlider').value = Math.min(fractalState.maxIterations, 2000);
if (fractalState.setMode === 'julia') {
const indicator = document.getElementById('juliaIndicator');
indicator.style.display = 'block';
document.getElementById('juliaCVal').textContent =
`${fractalState.juliaCRe.toFixed(6)} ${fractalState.juliaCIm >= 0 ? '+' : ''}${fractalState.juliaCIm.toFixed(6)}i`;
} else {
document.getElementById('juliaIndicator').style.display = 'none';
}
scheduleRender();
}
function deleteBookmark(index) {
const bookmarks = getBookmarks();
bookmarks.splice(index, 1);
saveBookmarks(bookmarks);
renderBookmarkList();
}
function renderBookmarkList() {
const panel = document.getElementById('bookmarkPanel');
const bookmarks = getBookmarks();
if (bookmarks.length === 0) {
panel.innerHTML = '<div style="padding:8px;font-size:12px;color:#666">No bookmarks saved</div>';
return;
}
panel.innerHTML = '';
bookmarks.forEach((b, i) => {
const item = document.createElement('div');
item.className = 'bookmark-item';
item.textContent = `${b.name} (${formatNumber(b.mag)}×)`;
item.addEventListener('click', () => {
loadBookmark(i);
panel.style.display = 'none';
});
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
if (confirm(`Delete bookmark "${b.name}"?`)) {
deleteBookmark(i);
}
});
panel.appendChild(item);
});
}
// Bookmark button toggle
document.getElementById('btnBookmark').addEventListener('click', () => {
const panel = document.getElementById('bookmarkPanel');
if (panel.style.display === 'block') {
panel.style.display = 'none';
} else {
renderBookmarkList();
panel.style.display = 'block';
}
});
/* ============================================================
SECTION 14 — Button Handlers
============================================================ */
document.getElementById('btnBack').addEventListener('click', zoomBack);
document.getElementById('btnReset').addEventListener('click', resetView);
document.getElementById('btnJulia').addEventListener('click', toggleJuliaMode);
/* ============================================================
SECTION 15 — URL Hash State (Shareable Links)
============================================================ */
function encodeStateToHash() {
const s = fractalState;
const params = new URLSearchParams();
params.set('re', s.centerRe.toString());
params.set('im', s.centerIm.toString());
params.set('mag', s.magnification.toString());
params.set('pal', s.paletteIndex);
params.set('off', s.colorOffset);
params.set('iter', s.maxIterations);
if (s.setMode === 'julia') {
params.set('mode', 'julia');
params.set('jc', `${s.juliaCRe},${s.juliaCIm}`);
}
return '#' + params.toString();
}
function loadStateFromHash() {
const hash = window.location.hash.slice(1);
if (!hash) return false;
try {
const params = new URLSearchParams(hash);
fractalState.centerRe = parseFloat(params.get('re')) || -0.5;
fractalState.centerIm = parseFloat(params.get('im')) || 0;
fractalState.magnification = Math.min(parseFloat(params.get('mag')) || 1, CONFIG.maxMagnification);
fractalState.paletteIndex = parseInt(params.get('pal')) || 0;
fractalState.colorOffset = parseFloat(params.get('off')) || 0;
fractalState.maxIterations = parseInt(params.get('iter')) || CONFIG.maxIterationsBase;
if (params.get('mode') === 'julia') {
fractalState.setMode = 'julia';
const jc = params.get('jc').split(',');
fractalState.juliaCRe = parseFloat(jc[0]) || -0.7269;
fractalState.juliaCIm = parseFloat(jc[1]) || 0.1889;
document.getElementById('juliaIndicator').style.display = 'block';
document.getElementById('juliaCVal').textContent =
`${fractalState.juliaCRe.toFixed(6)} ${fractalState.juliaCIm >= 0 ? '+' : ''}${fractalState.juliaCIm.toFixed(6)}i`;
}
document.getElementById('colorOffset').value = fractalState.colorOffset;
document.getElementById('iterSlider').value = Math.min(fractalState.maxIterations, 2000);
return true;
} catch {
return false;
}
}
// Update hash periodically (debounced)
let hashUpdateTimer = null;
function scheduleHashUpdate() {
if (hashUpdateTimer) clearTimeout(hashUpdateTimer);
hashUpdateTimer = setTimeout(() => {
window.history.replaceState(null, '', encodeStateToHash());
}, 1000);
}
/* ============================================================
SECTION 16 — Debug Interface (exposed on window)
============================================================ */
window.fractal = {
/**
* Compute iterations at a given complex coordinate.
* For Mandelbrot mode: c = (re, im), z starts at 0.
* For Julia mode: z starts at (re, im), c is the Julia constant.
*/
iterationsAt(re, im) {
const maxIter = fractalState.maxIterations;
if (fractalState.setMode === 'julia') {
return mandelbrotIteration(re, im, fractalState.juliaCRe, fractalState.juliaCIm, maxIter);
}
return mandelbrotC(re, im, maxIter);
},
/** Set the view programmatically. */
setView({ re, im, scale }) {
if (re !== undefined) fractalState.centerRe = re;
if (im !== undefined) fractalState.centerIm = im;
if (scale !== undefined) fractalState.magnification = Math.min(scale, CONFIG.maxMagnification);
updateIterationLimit();
scheduleRender();
},
/** Get current view state. */
getView() {
return {
re: fractalState.centerRe,
im: fractalState.centerIm,
mag: fractalState.magnification,
maxIter: fractalState.maxIterations,
mode: fractalState.setMode,
juliaC: { re: fractalState.juliaCRe, im: fractalState.juliaCIm },
};
},
/** Force a render. */
render() { scheduleRender(); },
/** Configuration object (read-only reference). */
config: CONFIG,
};
/* ============================================================
SECTION 17 — Initialization & Main Loop
============================================================ */
function init() {
// Load state from URL hash if present
const hasHashState = loadStateFromHash();
resizeCanvas();
initJuliaPreview();
buildPaletteUI();
// Initial render
updateIterationLimit();
scheduleRender();
// Show HUD initially, then auto-hide
showHUD();
hudTimer = setTimeout(hideHUD, CONFIG.hudHideTimeout);
// FPS counter
requestAnimationFrame(updateFPS);
// Handle resize
window.addEventListener('resize', () => {
resizeCanvas();
scheduleRender();
});
// Update hash on view changes (via mutation observer or periodic)
setInterval(scheduleHashUpdate, 2000);
if (!hasHashState) {
scheduleHashUpdate();
}
console.log('%c Fractal Explorer Loaded ', 'background:#333;color:#fff;padding:4px 8px;border-radius:4px;');
console.log('Debug interface available at window.fractal');
console.log('Methods: fractal.iterationsAt(re, im), fractal.setView({re,im,scale}), fractal.getView(), fractal.render()');
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
</body>
</html>
<!-- agent-meta {"model":"qwen3.6-27b-mtp","provider":"lmstudio","persona":"full","sessionId":"037234b1-d89b-4bc2-a073-f028486a69b8","tokensIn":559961,"tokensOut":24801,"tokensTotal":584762,"turns":19,"toolCalls":18,"failedToolCalls":0,"timestamp":"2026-07-29T22:42:41.660Z"} -->