oneshotlm
Compare all models on this prompt →

Mandelbrot explorer · solar-pro4

Render the Mandelbrot set with a smooth color gradient on a full-screen canvas, with click-and-drag to pan and mouse-wheel to zoom.

⛨ sandboxed Open in new tab ↗
transcript · 4 messages
user

Render the Mandelbrot set with a smooth color gradient on a full-screen canvas, with click-and-drag to pan and mouse-wheel to zoom.

assistant
thinking
The user wants a single self-contained HTML file that renders the Mandelbrot set with smooth coloring, pan with click-and-drag, and zoom with mouse wheel. Let me write a full-featured implementation.

Key features:
1. Full-screen canvas
2. Smooth color gradient (using the normalized iteration count with fractional part for smooth coloring)
3. Click-and-drag to pan
4. Mouse wheel to zoom (zoom towards cursor position)
5. All in one HTML file with inline CSS and JS, no external resources

Let me implement this carefully.

Smooth coloring approach: Use the normalized iteration count. For each point z, iterate z = z² + c. When |z| > 2, stop. The smooth color value is:
- n + 1 - log(log(|z|)) / log(2)
where n is the iteration count.

Then map this to a color gradient. I can use a palette or compute colors via sine-based method (like the famous Inigo Quilez's smooth coloring).

For performance, I'll render at a reasonable resolution. Let me use a worker or just direct pixel manipulation. For simplicity, direct manipulation with ImageData.

Let me think about the architecture:
- Canvas fills the window
- State: center (cx, cy), zoom (pixels per unit), maxIterations
- On each render: iterate over all pixels, compute Mandelbrot, map to color
- Pan: track mousedown, mousemove, mouseup
- Zoom: mouse wheel, zoom towards cursor

For smooth coloring, I'll use the log-log method.

For color: I'll use a palette approach with HSL or a custom gradient. Let me create a nice gradient - maybe using sine waves for a colorful result.

Let me write this out:

```javascript
function mandelbrot(cx, cy, maxIter) {
    let x = 0, y = 0;
    let x2 = 0, y2 = 0;
    let iter = 0;
    while (x2 + y2 <= 4 && iter < maxIter) {
        y = 2*x*y + cy;
        x = x2 - y2 + cx;
        x2 = x*x;
        y2 = y*y;
        iter++;
    }
    if (iter === maxIter) return -1; // inside set
    // smooth iteration count
    let log_zn = Math.log(x2 + y2) / 2;
    let nu = Math.log(log_zn / Math.log(2)) / Math.log(2);
    return iter + 1 - nu;
}
```

For color, I'll use a palette. Let me generate a palette with a nice gradient. Or use the sine method:

```javascript
function getColor(t) {
    // t is in [0, 1], mapped from smooth iteration count
    // Use sine-based coloring
    let r = Math.sin(2 * Math.PI * t + 0) * 0.5 + 0.5;
    let g = Math.sin(2 * Math.PI * t + 2) * 0.5 + 0.5;
    let b = Math.sin(2 * Math.PI * t + 4) * 0.5 + 0.5;
    return [r*255, g*255, b*255];
}
```

Actually let me use a more sophisticated palette. I'll create a gradient with multiple color stops.

Let me think about the rendering approach. For full-screen canvas, I need to:
1. Handle device pixel ratio for sharp rendering (or not - could be slow)
2. Recompute on each pan/zoom

Let me keep it simpler - render at canvas pixel resolution, no DPR scaling (for performance).

For the Mandelbrot, the aspect ratio matters. The viewport is: width/height in pixels, mapped to the complex plane. With zoom = pixels per unit, the visible range is:
- width in complex units: canvas.width / zoom
- height in complex units: canvas.height / zoom

To maintain aspect ratio, the viewport should be: 
- x range: [cx - (canvas.width/2)/zoom, cx + (canvas.width/2)/zoom]
- y range: [cy - (canvas.height/2)/zoom, cy + (canvas.height/2)/zoom]

But if we want to keep the aspect ratio consistent, we should adjust so that the complex range is square-ish. Actually for Mandelbrot it's common to just use the pixel aspect ratio directly. Let me use non-square pixels mapping - the y range is adjusted by aspect ratio.

Actually, let me keep it simple: use the pixel dimensions directly. The "zoom" defines how many pixels per unit in the x direction, and y is scaled by aspect ratio.

Let me code this up now.

For handling resize: listen to window resize and re-render.

For pan: on mousedown, record start position and start center. On mousemove, compute delta in complex coordinates and adjust center. On mouseup, finalize.

For zoom: on wheel, zoom by factor, zoom towards cursor position (so cursor stays at same complex point).

Let me write the complete file now.

I'll also add:
- A nice color palette (maybe predefined gradient stops)
- Smooth coloring with log-log
- Display of current position/zoom level maybe (optional - let me skip to keep it clean)
- Black interior (points in the set)

Let me use a good color scheme. I'll use a palette with multiple stops - deep blues, purples, oranges, golds - something visually striking.

Actually, I'll use the classic smooth coloring approach where I map the normalized iteration count through a palette. Let me create a palette array of 256 colors, generated from a gradient definition.

Here's my plan for the palette:
- Color stops at various positions with RGB values
- Interpolate between stops

Let me write this all out now.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mandelbrot Set</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
  canvas { display: block; width: 100%; height: 100%; cursor: grab; }
  canvas:active { cursor: grabbing; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
// ... all the JS
</script>
</body>
</html>
```

Let me write the full JS:

```javascript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

let width, height;
let centerX = -0.5;
let centerY = 0;
let zoom = 300; // pixels per unit
let maxIter = 256;
let isDragging = false;
let dragStartX, dragStartY, dragStartCenterX, dragStartCenterY;

function resize() {
    width = canvas.width = window.innerWidth;
    height = canvas.height = window.innerHeight;
    render();
}

function render() {
    const imageData = ctx.createImageData(width, height);
    const data = imageData.data;
    
    const aspect = width / height;
    const halfW = width / 2;
    const halfH = height / 2;
    
    for (let py = 0; py < height; py++) {
        for (let px = 0; px < width; px++) {
            const cx = (px - halfW) / zoom + centerX;
            const cy = (py - halfH) / zoom * aspect + centerY; // maintain aspect ratio
            // Actually let me think about this differently
            // ...
        }
    }
}
```

Hmm, let me think about the coordinate mapping more carefully.

If zoom = pixels per unit in x direction, then:
- x range: width / zoom (in complex units)
- For the y direction, to maintain the aspect ratio of the canvas, the y range should be height / zoom * (width/height) = width/zoom. Wait that's the same.

Actually if we want square pixels in the complex plane (which is the standard for Mandelbrot), then:
- x pixel to complex: (px - width/2) / zoom + centerX
- y pixel to complex: (py - height/2) / zoom + centerY

But then the visible region is rectangular (width/zoom by height/zoom), which is fine - the Mandelbrot set is in the complex plane and we just view a rectangular region.

Many implementations do: y = (py - height/2) / zoom * (width/height) + centerY — this makes the complex plane aspect ratio match the pixel aspect ratio. But actually, the standard Mandelbrot viewer just uses the same zoom for both axes and the viewport is naturally rectangular.

Let me just use:
- cx = (px - width/2) / zoom + centerX
- cy = (py - height/2) / zoom + centerY

This is the simplest and most intuitive. The viewport is rectangular but that's fine.

Wait, but if you zoom in a lot, the view becomes very zoomed in on both axes equally, so it's fine.

Actually, I realize the convention is often to keep the aspect ratio so that the complex plane region is square-ish, but for a full-screen canvas this doesn't matter much. Let me just use equal zoom for both axes.

Hmm, but if width != height, and we use equal zoom, then we're viewing a rectangular region. That's fine and standard.

Let me go with that.

Now for the smooth coloring:

```javascript
function smoothIter(cx, cy, maxIter) {
    let x = 0, y = 0;
    let x2 = 0, y2 = 0;
    let iter = 0;
    while (x2 + y2 <= 4 && iter < maxIter) {
        y = 2*x*y + cy;
        x = x2 - y2 + cx;
        x2 = x*x;
        y2 = y*y;
        iter++;
    }
    if (iter >= maxIter) return -1; // in the set
    // smooth value
    const log_zn = Math.log(x2 + y2) / 2;
    const nu = Math.log(log_zn / Math.log(2)) / Math.log(2);
    return iter + 1 - nu;
}
```

For color mapping, I'll create a nice palette. Let me use a gradient with these stops:
- 0.0: black
- 0.1: dark blue
- 0.3: purple
- 0.5: magenta/orange
- 0.7: gold
- 1.0: white/light

Or use the sine method for a smooth rainbow-like gradient.

Let me use a palette-based approach with interpolation.

Actually, let me use a well-known technique. I'll create a palette of 256 colors and use the fractional part to interpolate.

Here's a nice palette (inspired by common Mandelbrot visualizations):

```javascript
function buildPalette() {
    const palette = [];
    const stops = [
        { pos: 0.0, r: 0, g: 0, b: 0 },
        { pos: 0.1, r: 0, g: 0, b: 0.5 },
        { pos: 0.3, r: 0.2, g: 0, b: 0.8 },
        { pos: 0.5, r: 0.8, g: 0.2, b: 0.8 },
        { pos: 0.7, r: 1.0, g: 0.5, b: 0.2 },
        { pos: 0.9, r: 1.0, g: 0.9, b: 0.3 },
        { pos: 1.0, r: 1.0, g: 1.0, b: 1.0 }
    ];
    // interpolate
    for (let i = 0; i < 256; i++) {
        const t = i / 255;
        // find surrounding stops
        let lower = stops[0], upper = stops[stops.length-1];
        for (let j = 0; j < stops.length - 1; j++) {
            if (t >= stops[j].pos && t <= stops[j+1].pos) {
                lower = stops[j];
                upper = stops[j+1];
                break;
            }
        }
        const range = upper.pos - lower.pos;
        const frac = range === 0 ? 0 : (t - lower.pos) / range;
        palette.push({
            r: Math.round((lower.r + (upper.r - lower.r) * frac) * 255),
            g: Math.round((lower.g + (upper.g - lower.g) * frac) * 255),
            b: Math.round((lower.b + (upper.b - lower.b) * frac) * 255)
        });
    }
    return palette;
}
```

Then for coloring:
```javascript
const smooth = smoothIter(cx, cy, maxIter);
if (smooth < 0) {
    // inside set - black
    data[idx] = 0; data[idx+1] = 0; data[idx+2] = 0; data[idx+3] = 255;
} else {
    const t = smooth % 1; // fractional part
    const idx2 = Math.floor(smooth) % 256;
    // Use the fractional part for smooth interpolation
    const col = palette[Math.floor(t * 255)];
    // Actually we want to use smooth value mapped to palette
    const paletteIdx = Math.floor((smooth % 256));
    // Or just use smooth value mod 256
    // The smooth coloring should use the fractional part for interpolation
}
```

Let me think about this more carefully. The standard smooth coloring approach:

1. Compute the smooth iteration count (n + 1 - log(log(|z|))/log(2))
2. Take the fractional part or use the whole value mapped to palette

Common approach: use `smooth % 1` (fractional part) to pick a color, OR use the whole smooth value mapped through a cyclic palette.

For a smooth gradient without banding, we want the color to vary smoothly with the smooth iteration count. The typical approach:

- Map smooth value to [0, 1] range (e.g., smooth / maxIter or using log scaling)
- Use this to pick from a palette

Or use a cyclic palette where the color wraps around based on the smooth value.

Let me use this approach:
- smooth value can be large (up to maxIter)
- Take smooth value, map to palette index: `Math.floor(smooth) % paletteSize`
- Use fractional part for interpolation between adjacent palette entries

```javascript
const n = Math.floor(smooth);
const f = smooth - n; // fractional part
const idx1 = n % 256;
const idx2 = (n + 1) % 256;
const c1 = palette[idx1];
const c2 = palette[idx2];
// interpolate
const r = Math.round(c1.r + (c2.r - c1.r) * f);
const g = Math.round(c1.g + (c2.g - c1.g) * f);
const b = Math.round(c1.b + (c2.b - c1.b) * f);
```

This gives smooth coloring with a cyclic palette. But the palette needs to be cyclic (end matches beginning) for this to look good without seams.

Alternatively, I can use the log-based color mapping where the color depends on log(smooth) which gives more uniform color distribution.

Let me use a combination: map smooth value through a logarithmic scale to get a t value in [0, 1), then use that to pick from a non-cyclic palette.

Actually, let me keep it simple and effective. I'll use the smooth iteration value, map it to a color using a palette with interpolation, and use modulo for cycling.

Let me use a palette that's designed to be cyclic (first color ≈ last color).

Or better yet, let me use the approach where:
- t = smooth / maxIter (normalized)
- Use t to interpolate in a gradient

But this loses the cyclic nature. For deep zooms, you want cycling.

Let me just go with the cyclic palette approach with a well-designed palette.

Here's my final approach:

1. Palette of 256 colors, designed to be cyclic (smooth transition from end to start)
2. Color index = smooth % 256 (integer part)
3. Interpolate using fractional part

For the palette, I'll use sine-based colors which are naturally smooth:

```javascript
function buildPalette(size) {
    const palette = [];
    for (let i = 0; i < size; i++) {
        const t = i / size;
        // Sine-based color cyclic palette
        const r = Math.sin(2 * Math.PI * t * 3 + 0) * 0.5 + 0.5;
        const g = Math.sin(2 * Math.PI * t * 3 + 2.094) * 0.5 + 0.5;
        const b = Math.sin(2 * Math.PI * t * 3 + 4.188) * 0.5 + 0.5;
        palette.push([r * 255, g * 255, b * 255]);
    }
    return palette;
}
```

This creates a smooth cyclic rainbow-like palette. But it might be too "rainbow". Let me tune it.

Actually, for a more sophisticated look, I'll use a custom palette with several color stops that's designed to be cyclic.

Let me try this palette:
- It goes through: dark blue → cyan → green → yellow → red → magenta → dark blue

This is cyclic and looks nice.

Let me define it with stops and interpolate, making sure the first and last colors match.

OK let me just code it up. I'll use a sine-based palette but tune the frequencies and phases for a nice look.

Actually, let me use a more artistic palette. Here's one that works well:

```javascript
const palette = [];
for (let i = 0; i < 256; i++) {
    const t = i / 256;
    // Custom cyclic palette
    const r = Math.pow(Math.sin(2 * Math.PI * t * 1.5 + 0.0) * 0.5 + 0.5, 1.5) * 255;
    const g = Math.pow(Math.sin(2 * Math.PI * t * 1.5 + 2.094) * 0.5 + 0.5, 1.5) * 255;
    const b = Math.pow(Math.sin(2 * Math.PI * t * 1.5 + 4.188) * 0.5 + 0.5, 1.5) * 255;
    palette.push([r, g, b]);
}
```

Hmm, this is getting complicated. Let me just use a simple, proven approach.

I'll create a palette with explicit color stops and linear interpolation, designed to be cyclic.

```javascript
function buildCyclicPalette(n) {
    // Color stops (must be cyclic: first ≈ last)
    const stops = [
        [0.00, 0.00, 0.15, 0.30],  // dark blue
        [0.20, 0.00, 0.40, 0.70],  // medium blue
        [0.40, 0.00, 0.70, 0.70],  // cyan
        [0.55, 0.20, 0.85, 0.40],  // green
        [0.70, 0.80, 0.80, 0.20],  // yellow
        [0.85, 0.95, 0.30, 0.10],  // orange
        [0.95, 0.70, 0.10, 0.30],  // red
        [1.00, 0.40, 0.20, 0.50],  // magenta-ish (matches start)
    ];
    const palette = [];
    for (let i = 0; i < n; i++) {
        const t = i / (n - 1);
        // find segment
        let lo = stops[0], hi = stops[stops.length - 1];
        for (let j = 0; j < stops.length - 1; j++) {
            if (t >= stops[j][0] && t <= stops[j+1][0]) {
                lo = stops[j];
                hi = stops[j+1];
                break;
            }
        }
        const span = hi[0] - lo[0];
        const f = span === 0 ? 0 : (t - lo[0]) / span;
        palette.push([
            Math.round((lo[1] + (hi[1] - lo[1]) * f) * 255),
            Math.round((lo[2] + (hi[2] - lo[2]) * f) * 255),
            Math.round((lo[3] + (hi[3] - lo[3]) * f) * 255)
        ]);
    }
    return palette;
}
```

Now for the interactive features:

Pan: 
- mousedown: record start position, start center
- mousemove: if dragging, compute new center based on mouse delta (in complex coords)
- mouseup: stop dragging

Zoom (mouse wheel):
- zoom towards cursor position
- On wheel event, get cursor position in complex coords before zoom
- Apply zoom factor
- Adjust center so cursor stays at same complex point

```javascript
canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const zoomFactor = e.deltaY > 0 ? 1.1 : 1/1.1;
    // cursor complex coords before zoom
    const px = e.offsetX;
    const py = e.offsetY;
    const cx_before = (px - width/2) / zoom + centerX;
    const cy_before = (py - height/2) / zoom + centerY;
    // apply zoom
    zoom *= zoomFactor;
    // adjust center
    centerX = cx_before - (px - width/2) / zoom;
    centerY = cy_before - (py - height/2) / zoom;
    render();
});
```

Wait, the wheel event coordinates need to be relative to the canvas. Let me use getBoundingClientRect or offsetX/offsetY.

Actually, let me use e.clientX/Y and subtract canvas bounding rect.

For pan, I need to convert mouse delta (in pixels) to complex delta:
- deltaX_complex = deltaX_pixels / zoom
- deltaY_complex = deltaY_pixels / zoom

```javascript
canvas.addEventListener('mousedown', (e) => {
    isDragging = true;
    dragStartX = e.clientX;
    dragStartY = e.clientY;
    dragStartCenterX = centerX;
    dragStartCenterY = centerY;
    canvas.style.cursor = 'grabbing';
});

window.addEventListener('mousemove', (e) => {
    if (!isDragging) return;
    const dx = e.clientX - dragStartX;
    const dy = e.clientY - dragStartY;
    centerX = dragStartCenterX - dx / zoom;
    centerY = dragStartCenterY - dy / zoom;
    render();
});

window.addEventListener('mouseup', () => {
    if (isDragging) {
        isDragging = false;
        canvas.style.cursor = 'grab';
    }
});
```

Wait, there's a problem: on mousemove during drag, we're calling render() on every move, which could be expensive. Let me throttle or use a different approach. For simplicity, I'll render on each move but use a reasonable maxIter (maybe lower during drag, or just accept the cost).

Actually, for a smooth experience, I should render at a lower quality during drag and full quality on mouseup. Let me implement that.

Or simpler: just render on each move but keep maxIter moderate. For a full-screen canvas, rendering 256 iterations per pixel for a 1920x1080 canvas is ~500M iterations, which might be slow.

Let me think about performance. A 1920x1080 canvas = 2,073,600 pixels. At 256 max iterations, worst case is ~500M iterations. At maybe 100M iterations per second (optimistic for JS), that's 5 seconds. Too slow.

I should:
1. Use a lower maxIter for initial render and during interactions
2. Maybe use adaptive maxIter based on zoom level
3. Or render at lower resolution and scale up

For simplicity, let me use a moderate maxIter (like 100-200) and accept that renders may take a moment. For desktop, modern JS engines can do maybe 50-100M iterations/sec for simple loops, so 2M pixels * 100 iter = 200M iterations = 2-4 seconds. Still slow.

Let me optimize the inner loop and use a reasonable maxIter. I can also add a progressive rendering approach or use a lower resolution during interactions.

Actually, let me just set maxIter based on zoom level: the deeper you zoom, the more iterations you need. At zoom 300 (default view), maxIter = 50 is plenty. At zoom 10000, maxIter = 200 might be needed.

Let me use: maxIter = Math.max(50, Math.min(500, Math.floor(Math.log2(zoom)) * 40))

At zoom 300: log2(300) ≈ 8.2, * 40 = 328... that's too high for default.
Let me use: maxIter = Math.max(30, Math.min(300, Math.floor(zoom / 10)))

At zoom 300: 30
At zoom 10000: 1000 → capped at 300
At zoom 100000: 10000 → capped at 300

Hmm, let me think about what maxIter is actually needed. For the Mandelbrot set, the number of iterations needed to determine if a point escapes scales roughly with log2(zoom) near the boundary. But for most points, they either escape quickly or don't escape at all.

A common formula: maxIter = 100 + 50 * log2(zoom / initialZoom)

At default zoom 300, initialZoom 300: maxIter = 100
At zoom 10000: 100 + 50 * log2(33.3) = 100 + 50 * 5.0 = 350
At zoom 1000000: 100 + 50 * log2(3333) = 100 + 50 * 11.7 = 685

Let me use a simpler approach: maxIter = 100 + 20 * Math.log2(zoom / 200), capped at some max.

Actually, let me just use a fixed maxIter of 200 for the default view and increase it for deep zooms. Let me use:

```javascript
function getMaxIter() {
    const base = 100;
    const extra = 30 * Math.log2(Math.max(1, zoom / 300));
    return Math.min(1000, Math.floor(base + extra));
}
```

At zoom 300: 100
At zoom 10000: 100 + 30 * log2(33.3) = 100 + 30*5 = 250
At zoom 100000: 100 + 30 * log2(333.3) = 100 + 30*8.38 = 351

That seems reasonable.

For performance, I should also consider that rendering the full canvas on every mousemove during drag is expensive. Let me use a throttle: only render every N ms during drag, and do a full render on mouseup.

Actually, for simplicity and responsiveness, let me render at a lower resolution during drag. I'll render every other pixel and scale up, or use a smaller canvas.

Hmm, this is getting complex. Let me just render at full resolution but with a lower maxIter during drag. And on mouseup, do a full render.

Actually, let me keep it simple: render on every frame during drag with the current settings. If it's slow, the user can wait. Modern browsers can handle this reasonably well for moderate canvas sizes.

Let me also consider using requestAnimationFrame for rendering during drag.

OK let me just write the code. I'll optimize the inner loop as much as possible.

One more optimization: I can precompute the palette and use it. The inner loop just does the Mandelbrot iteration, then a quick palette lookup.

Let me write the final code now. I'll aim for a clean, well-structured implementation.

For the color, I want something visually stunning. Let me use a palette that goes through deep space colors: dark navy → purple → hot pink → orange → gold → white.

Here's my final palette design (cyclic):

```javascript
const paletteStops = [
    [0.00, 0.00, 0.00, 0.05],   // near black
    [0.15, 0.00, 0.02, 0.15],   // very dark blue
    [0.30, 0.05, 0.05, 0.40],   // dark purple
    [0.45, 0.30, 0.10, 0.60],   // purple
    [0.55, 0.60, 0.20, 0.70],   // magenta
    [0.65, 0.85, 0.30, 0.40],   // pink
    [0.75, 0.95, 0.50, 0.20],   // orange
    [0.85, 1.00, 0.75, 0.10],   // gold
    [0.95, 1.00, 0.95, 0.70],   // light yellow
    [1.00, 1.00, 1.00, 1.00],   // white
];
```

Wait, this isn't cyclic (1.0 != 0.0). For cyclic palette, I need to either:
1. Make it cyclic by ensuring smooth[0] and smooth[1] are close
2. Not use modulo, instead use a one-shot palette mapped via log scale

Let me go with option 2: map the smooth value through a log scale to [0, 1), then use a non-cyclic palette.

```javascript
// After computing smooth value:
if (smooth < 0) { /* inside set - black */ }
else {
    // Map to [0, 1) using log scale
    const t = Math.log(smooth + 1) / Math.log(maxIter + 1);
    const paletteIdx = Math.floor(t * 255);
    const frac = (t * 255) - paletteIdx;
    const idx1 = Math.min(paletteIdx, 255);
    const idx2 = Math.min(paletteIdx + 1, 255);
    // interpolate
}
```

But this means the colors don't cycle as you zoom deeper - you'll run out of palette and everything becomes the last color. For deep zooms, cycling is better.

OK, let me use a cyclic palette. I'll make the first and last colors close enough.

Actually, the simplest cyclic approach: use a palette where color(t) = color(t + 1). With the sine-based approach, this is automatically satisfied since sin(2π * (t+1)) = sin(2π * t).

Let me use the sine-based palette with tuned parameters for a beautiful look:

```javascript
function paletteColor(t) {
    // t in [0, 1)
    const r = Math.pow(Math.sin(2 * Math.PI * t * 1.0 + 0.00) * 0.5 + 0.5, 0.8) * 255;
    const g = Math.pow(Math.sin(2 * Math.PI * t * 1.0 + 2.09) * 0.5 + 0.5, 0.8) * 255;
    const b = Math.pow(Math.sin(2 * Math.PI * t * 1.0 + 4.19) * 0.5 + 0.5, 0.8) * 255;
    return [r, g, b];
}
```

With power 0.8, the colors are more saturated. The frequency of 1.0 means one full cycle through the rainbow per unit of t.

But this gives a pure rainbow. Let me tune it to be more artistic.

Actually, you know what, let me use a custom palette that I know looks good. Let me define it with explicit RGBA stops and interpolate:

```javascript
const palette = new Array(256);
const stops = [
    { t: 0.00, r: 0, g: 0, b: 5 },
    { t: 0.10, r: 0, g: 0, b: 30 },
    { t: 0.25, r: 0, g: 10, b: 80 },
    { t: 0.40, r: 20, g: 30, b: 140 },
    { t: 0.55, r: 80, g: 50, b: 180 },
    { t: 0.65, r: 160, g: 60, b: 160 },
    { t: 0.75, r: 210, g: 80, b: 80 },
    { t: 0.85, r: 240, g: 140, b: 30 },
    { t: 0.93, r: 255, g: 210, b: 80 },
    { t: 1.00, r: 255, g: 240, b: 180 },
];
```

For cyclic behavior, I want stops[0] ≈ stops[last]. Let me adjust:
- t=0: (0, 0, 20)
- t=1: (0, 0, 20) or close

Actually, for the Mandelbrot with smooth coloring, the cycling is handled by taking the fractional part of the smooth value and using it to index into the palette. If the palette is cyclic (ends where it begins), the transition is seamless.

Let me use a palette where the first and last colors are the same:

```javascript
const stops = [
    { t: 0.00, r: 0, g: 0, b: 10 },   // dark navy
    { t: 0.15, r: 0, g: 5, b: 50 },    // navy
    { t: 0.30, r: 10, g: 10, b: 100 }, // blue
    { t: 0.45, r: 40, g: 30, b: 150 }, // purple-blue
    { t: 0.55, r: 100, g: 40, b: 170 }, // purple
    { t: 0.65, r: 170, g: 50, b: 130 }, // magenta
    { t: 0.75, r: 210, g: 80, b: 70 },  // red-orange
    { t: 0.85, r: 240, g: 140, b: 20 }, // orange
    { t: 0.93, r: 255, g: 200, b: 60 }, // gold
    { t: 1.00, r: 0, g: 0, b: 10 },     // back to dark navy (cyclic)
];
```

Wait, going from gold (255, 200, 60) to dark navy (0, 0, 10) is a huge jump. That won't look good.

For a cyclic palette, I need the colors to transition smoothly from end to start. Let me redesign:

Option: Make a palette that cycles through a color wheel but with a specific hue path.

Or: Don't make it cyclic. Instead, use the smooth value modulo some range, and use a non-cyclic palette. The "banding" that occurs at the wrap point is actually a known artifact of Mandelbrot rendering that many people accept or even like.

Let me go with a non-cyclic palette that maps the smooth value (normalized) to colors. The smooth value is mapped via a log scale so that deep zooms still show detail.

Here's my final approach:

```javascript
// Build palette
const paletteSize = 1024;
const palette = [];
for (let i = 0; i < paletteSize; i++) {
    const t = i / (paletteSize - 1);
    // Define color stops
    let r, g, b;
    if (t < 0.1) {
        // black to dark blue
        const f = t / 0.1;
        r = 0; g = 0; b = Math.floor(f * 20);
    } else if (t < 0.3) {
        const f = (t - 0.1) / 0.2;
        r = 0; g = Math.floor(f * 10); b = 20 + Math.floor(f * 60);
    } else if (t < 0.5) {
        const f = (t - 0.3) / 0.2;
        r = Math.floor(f * 30); g = 10 + Math.floor(f * 20); b = 80 + Math.floor(f * 70);
    } else if (t < 0.7) {
        const f = (t - 0.5) / 0.2;
        r = 30 + Math.floor(f * 70); g = 30 + Math.floor(f * 10); b = 150 - Math.floor(f * 50);
    } else if (t < 0.85) {
        const f = (t - 0.7) / 0.15;
        r = 100 + Math.floor(f * 110); g = 40 + Math.floor(f * 40); b = 100 - Math.floor(f * 80);
    } else {
        const f = (t - 0.85) / 0.15;
        r = 210 + Math.floor(f * 45); g = 80 + Math.floor(f * 130); b = 20 + Math.floor(f * 30);
    }
    palette.push([Math.min(255, r), Math.min(255, g), Math.min(255, b)]);
}
```

This is getting messy. Let me use a cleaner approach with explicit interpolation between stops:

OK, I'm overthinking this. Let me just use a well-known technique. I'll use the "normalized iteration count" with a palette that's a simple gradient, and I'll make it work well.

Let me use this approach which is clean and produces nice results:

1. Compute smooth iteration value `mu`
2. For points in the set (mu < 0), color black
3. For points outside, compute `t = Math.log(mu) / Math.log(maxIter)` to get a value in [0, 1] (approximately)
4. Actually, a better mapping: `t = mu / maxIter` but this doesn't work well for deep zooms
5. Use `t = Math.log(1 + mu) / Math.log(1 + maxIter)` for a log mapping

Then use `t` to look up in a gradient palette.

For the palette, I'll use a simple multi-stop gradient:

```javascript
const palette = [];
const stops = [
    [0.00, 0, 0, 0],       // black (for interior)
    [0.05, 0, 0, 15],     // very dark blue
    [0.15, 5, 5, 60],     // dark blue
    [0.30, 15, 20, 120],  // blue
    [0.45, 60, 40, 160],  // indigo
    [0.55, 120, 50, 160], // purple
    [0.65, 180, 60, 120], // magenta
    [0.75, 220, 90, 60],  // red
    [0.85, 245, 150, 30], // orange
    [0.93, 255, 210, 70], // gold
    [1.00, 255, 245, 200], // pale yellow/white
];
```

Wait, but I want the interior (points in the set) to be black, and the exterior to use the palette. The smooth iteration value for interior points is -1 (or I can use a flag). For exterior points, the smooth value is positive.

So the mapping is:
- interior: black
- exterior: map smooth value through log scale to [0, 1], then look up in palette

But the palette above starts at black (0,0,0) at t=0. For exterior points near the boundary, mu is small (just above 0), so t is near 0, and the color is near black. That's fine - it creates a nice gradient from the boundary outward.

For the palette to work with the log mapping, I need the palette to span from near-black to bright colors. The stops above do that.

But wait, for deep zooms, mu can be very large (up to maxIter). The log mapping `t = log(1 + mu) / log(1 + maxIter)` will map large mu values close to 1, so they'll get the bright end of the palette. That's good.

For shallow zooms, mu is smaller, and the log mapping spreads the colors more evenly. That's also good.

Let me finalize this approach.

For the palette lookup with interpolation:

```javascript
function getColor(mu, maxIter) {
    if (mu < 0) return [0, 0, 0]; // interior
    const t = Math.log(1 + mu) / Math.log(1 + maxIter);
    // clamp
    const ct = Math.min(1, Math.max(0, t));
    // find segment in stops
    let lo = stops[0], hi = stops[stops.length - 1];
    for (let i = 0; i < stops.length - 1; i++) {
        if (ct >= stops[i][0] && ct <= stops[i+1][0]) {
            lo = stops[i];
            hi = stops[i+1];
            break;
        }
    }
    const span = hi[0] - lo[0];
    const f = span === 0 ? 0 : (ct - lo[0]) / span;
    return [
        Math.round(lo[1] + (hi[1] - lo[1]) * f),
        Math.round(lo[2] + (hi[2] - lo[2]) * f),
        Math.round(lo[3] + (hi[3] - lo[3]) * f)
    ];
}
```

OK, this is a solid approach. Let me now write the complete code.

One more thing: for the pan and zoom, I need to handle the coordinate transformations correctly.

Let me define:
- `centerX`, `centerY`: the complex coordinates at the center of the canvas
- `zoom`: pixels per unit in the complex plane (same for x and y)

For a pixel (px, py), the complex coordinates are:
- cx = (px - width/2) / zoom + centerX
- cy = (py - height/2) / zoom + centerY

For panning: when the mouse moves by (dx, dy) pixels, the center moves by (-dx/zoom, -dy/zoom) in complex coordinates.

For zooming towards the cursor:
1. Before zoom: compute complex coords of cursor: (cx_c, cy_c)
2. Apply zoom: zoom *= factor
3. After zoom: we want the cursor to still map to (cx_c, cy_c)
   - new_centerX = cx_c - (px - width/2) / new_zoom
   - new_centerY = cy_c - (py - height/2) / new_zoom

Let me verify: 
- Before: cx_c = (px - width/2) / zoom + centerX
- After: cx_c = (px - width/2) / new_zoom + new_centerX
- So: new_centerX = cx_c - (px - width/2) / new_zoom
- = (px - width/2) / zoom + centerX - (px - width/2) / new_zoom
- = centerX + (px - width/2) * (1/zoom - 1/new_zoom)
- = centerX + (px - width/2) * (new_zoom - zoom) / (zoom * new_zoom)

OK, that's correct.

Now let me write the complete HTML file.

I'll also handle:
- Window resize: re-render
- The rendering should be efficient

For the rendering, I'll use ImageData and direct pixel manipulation.

Let me also think about the rendering loop. For a full-screen canvas (say 1920x1080), rendering at 200 iterations per pixel is ~400M iterations. In modern JS, a tight loop can do maybe 50-100M simple iterations per second. So this could take 4-8 seconds. That's too slow for interactive use.

Optimizations:
1. Use a lower maxIter for the default view
2. Use 30-bit or 20-bit precision instead of full 64-bit float (not easily doable in JS)
3. Early bailout: most points escape quickly
4. Use a lower resolution during interactions

For the default view (zoom 300, viewing the entire set), most points either escape within a few iterations or are in the set. The average number of iterations is much less than maxIter. So the actual runtime is closer to (number of pixels) * (average iterations) which might be 2M * 20 = 40M iterations, taking ~0.5 seconds. That's acceptable.

For deep zooms near the boundary, many points take close to maxIter iterations, so it slows down. But that's expected.

Let me set maxIter adaptively:
- Default view: maxIter = 80
- Based on zoom: maxIter = 80 + 20 * log2(zoom / 300), capped at 1000

Let me also add a loading indicator or just accept the delay.

For the pan during drag, I'll render at lower maxIter and on mouseup do a full render.

Actually, let me simplify: I'll just render on each interaction. If it's slow, it's slow. Modern computers should handle it OK for reasonable zoom levels.

Let me also consider using OffscreenCanvas or a Web Worker for the rendering, but that adds complexity. Let me keep it simple with direct rendering on the main thread.

One more optimization: I can use the symmetry of the Mandelbrot set (it's symmetric about the real axis). But for panning and zooming, the viewport might not be symmetric, so this doesn't help much.

OK, let me write the code now. I'll aim for a clean, complete implementation.

Let me also add a nice touch: show the current zoom level and center coordinates somewhere (maybe in a tooltip or overlay). Or maybe just keep it clean without UI elements.

I'll keep it clean - just the fractal on a full-screen canvas with the interaction controls.

Let me also make the cursor change to indicate pan mode.

Here's my complete implementation plan:

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Mandelbrot Set</title>
  <style>
    /* full-screen canvas, no margins */

  </style>
</head>
<body>
  <canvas id="c"></canvas>
  <script>
    // Setup canvas
    // State: centerX, centerY, zoom, maxIter
    // Palette generation
    // Render function
    // Event handlers: mousedown, mousemove, mouseup, wheel, resize
    // Initial render
  </script>
</body>
</html>
```

Let me write it all out now.

Actually, I realize I should think about one more thing: the `wheel` event. I need to prevent the default scroll behavior on the canvas. I'll use `e.preventDefault()` and set `passive: false` for the event listener.

Also, for touch devices, I should handle touch events for pan. But let me keep it to mouse for now.

Let me also consider: should I use `e.offsetX/Y` or `e.clientX/Y - rect`? `offsetX/Y` is relative to the target element, which is what I want. But it might not be available in all browsers or for all events. Let me use `e.clientX/Y` with `getBoundingClientRect()` for robustness.

Actually, `offsetX/Y` works fine for mouse events on canvas. Let me use that.

For wheel events, `e.deltaX` and `e.deltaY` give the scroll amount. `e.deltaY > 0` means scrolling down (zoom out), `e.deltaY < 0` means scrolling up (zoom in).

Let me write the final code:

```javascript
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
let W, H;
let cx = -0.5, cy = 0;  // center in complex plane
let zoom = 300;  // pixels per unit
let maxIter = 100;
let dragging = false;
let dragStart = { x: 0, y: 0, cx: 0, cy: 0 };

// Palette
const palette = [];
const stops = [
    [0.00, 0, 0, 0],
    [0.02, 0, 0, 8],
    [0.08, 0, 0, 30],
    [0.18, 2, 8, 70],
    [0.30, 8, 20, 120],
    [0.42, 30, 40, 155],
    [0.52, 70, 50, 160],
    [0.62, 130, 55, 130],
    [0.72, 180, 70, 80],
    [0.82, 220, 110, 35],
    [0.90, 245, 170, 40],
    [0.95, 255, 215, 90],
    [1.00, 255, 245, 180],
];

function buildPalette() {
    for (let i = 0; i < 1024; i++) {
        const t = i / 1023;
        let lo = stops[0], hi = stops[stops.length - 1];
        for (let j = 0; j < stops.length - 1; j++) {
            if (t >= stops[j][0] && t <= stops[j+1][0]) {
                lo = stops[j];
                hi = stops[j+1];
                break;
            }
        }
        const span = hi[0] - lo[0];
        const f = span === 0 ? 0 : (t - lo[0]) / span;
        palette.push([
            Math.round(lo[1] + (hi[1] - lo[1]) * f),
            Math.round(lo[2] + (hi[2] - lo[2]) * f),
            Math.round(lo[3] + (hi[3] - lo[3]) * f)
        ]);
    }
}

function getMaxIter() {
    return Math.min(1000, Math.max(50, Math.floor(80 + 25 * Math.log2(Math.max(1, zoom / 200)))));
}

function render() {
    maxIter = getMaxIter();
    W = canvas.width = window.innerWidth;
    H = canvas.height = window.innerHeight;
    
    const imageData = ctx.createImageData(W, H);
    const data = imageData.data;
    const halfW = W / 2;
    const halfH = H / 2;
    
    for (let py = 0; py < H; py++) {
        for (let px = 0; px < W; px++) {
            const ccx = (px - halfW) / zoom + cx;
            const ccy = (py - halfH) / zoom + cy;
            
            let x = 0, y = 0, x2 = 0, y2 = 0;
            let iter = 0;
            while (x2 + y2 <= 4 && iter < maxIter) {
                y = 2 * x * y + ccy;
                x = x2 - y2 + ccx;
                x2 = x * x;
                y2 = y * y;
                iter++;
            }
            
            const idx = (py * W + px) * 4;
            if (iter >= maxIter) {
                data[idx] = 0;
                data[idx + 1] = 0;
                data[idx + 2] = 0;
                data[idx + 3] = 255;
            } else {
                const log_zn = Math.log(x2 + y2) / 2;
                const nu = Math.log(log_zn / Math.LN2) / Math.LN2;
                const mu = iter + 1 - nu;
                const t = Math.log(1 + mu) / Math.log(1 + maxIter);
                const ct = Math.min(1, Math.max(0, t));
                // palette lookup
                const pi = ct * 1023;
                const i0 = Math.floor(pi);
                const i1 = Math.min(i0 + 1, 1023);
                const f = pi - i0;
                const p0 = palette[i0];
                const p1 = palette[i1];
                data[idx] = Math.round(p0[0] + (p1[0] - p0[0]) * f);
                data[idx + 1] = Math.round(p0[1] + (p1[1] - p0[1]) * f);
                data[idx + 2] = Math.round(p0[2] + (p1[2] - p0[2]) * f);
                data[idx + 3] = 255;
            }
        }
    }
    
    ctx.putImageData(imageData, 0, 0);
}

// Events
canvas.addEventListener('mousedown', (e) => {
    dragging = true;
    dragStart.x = e.clientX;
    dragStart.y = e.clientY;
    dragStart.cx = cx;
    dragStart.cy = cy;
    canvas.style.cursor = 'grabbing';
});

window.addEventListener('mousemove', (e) => {
    if (!dragging) return;
    const dx = e.clientX - dragStart.x;
    const dy = e.clientY - dragStart.y;
    cx = dragStart.cx - dx / zoom;
    cy = dragStart.cy - dy / zoom;
    render();
});

window.addEventListener('mouseup', () => {
    if (dragging) {
        dragging = false;
        canvas.style.cursor = 'grab';
    }
});

canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15;
    const px = e.offsetX;
    const py = e.offsetY;
    const c_cx = (px - W / 2) / zoom + cx;
    const c_cy = (py - H / 2) / zoom + cy;
    zoom *= factor;
    cx = c_cx - (px - W / 2) / zoom;
    cy = c_cy - (py - H / 2) / zoom;
    render();
}, { passive: false });

window.addEventListener('resize', () => {
    render();
});

// Init
buildPalette();
canvas.style.cursor = 'grab';
render();
```

Wait, there's an issue with the wheel event: I'm using W and H which are set in render(). But if the wheel fires before the first render, W and H might be 0. Let me initialize them.

Also, for the wheel event, I need to make sure W and H are up to date. Let me set them at the start.

Let me also handle the case where the wheel event fires when W/H haven't been set yet.

Let me restructure: set W and H on load and resize, and use them in the wheel handler.

Actually, let me just call render() once at init to set W and H, and then all event handlers will work.

Hmm, but render() also sets W and H. Let me just set them once at init.

Let me restructure the code slightly:

```javascript
let W, H;
function updateSize() {
    W = canvas.width = window.innerWidth;
    H = canvas.height = window.innerHeight;
}
```

And call updateSize() in render() and on resize.

OK, I think I have a solid plan. Let me write the final code.

One more consideration: the cursor. I want:
- Default: grab cursor (indicates draggable)
- During drag: grabbing cursor
- On wheel: default cursor

Let me also add a nice visual touch: when the page loads, maybe render at a lower resolution first and then refine? No, let me keep it simple - just render at full resolution.

Let me also think about what happens if the user zooms in very deep. The maxIter will increase, and rendering will get slower. At some point, it might become impractically slow. But that's the nature of Mandelbrot rendering. I'll cap maxIter at 1000.

For very deep zooms, I could also use the fact that the Mandelbrot set has self-similarity and use perturbation theory, but that's extremely complex and not feasible for this implementation.

OK, let me write the final code now. I'm confident in the approach.

Let me also double-check the smooth iteration formula:

The standard smooth iteration count (also called the "normalized iteration count") is:

n + 1 - log(log(|z_n|)) / log(2)

where:
- n is the number of iterations before bailout
- z_n is the value at bailout (|z_n| > 2)
- log is the natural logarithm

In my code:
- iter is the number of iterations (0-indexed, incremented after each iteration)
- After the loop, iter is the number of iterations performed
- x2 + y2 = |z|^2
- log_zn = log(|z|^2) / 2 = log(|z|)
- nu = log(log_zn / log(2)) / log(2) = log(log(|z|) / log(2)) / log(2)

So mu = iter + 1 - nu

Wait, let me be careful. After the loop:
- iter is the count of iterations performed
- If the loop exited because |z| > 2, then iter is the iteration at which it escaped
- If the loop exited because iter >= maxIter, then the point is in the set

For the smooth count:
- mu = iter + 1 - log(log(|z|)) / log(2)
- where |z| is the magnitude at bailout

In my code:
- x2 + y2 = |z|^2
- log_zn = Math.log(x2 + y2) / 2 = log(|z|^2) / 2 = log(|z|)
- nu = Math.log(log_zn / Math.LN2) / Math.LN2

Wait, Math.LN2 = log(2). So:
- log_zn / Math.LN2 = log(|z|) / log(2) = log2(|z|)
- nu = Math.log(log2(|z|)) / Math.LN2 = log(log2(|z|)) / log(2) = log2(log2(|z|))

So mu = iter + 1 - log2(log2(|z|))

Hmm, that's the formula. Let me verify with a known example.

For c = 0.25 + 0.5i:
- z_0 = 0
- z_1 = 0.25 + 0.5i, |z_1|^2 = 0.0625 + 0.25 = 0.3125, |z_1| ≈ 0.559
- z_2 = (0.25 + 0.5i)^2 + 0.25 + 0.5i = 0.0625 - 0.25 + 0.25i + 0.25 + 0.5i = 0.0625 + 0.75i, |z_2|^2 = 0.00390625 + 0.5625 = 0.5664, |z_2| ≈ 0.753
- ... this will take a few more iterations to escape.

Anyway, the formula is correct. Let me proceed.

One edge case: if |z| is very close to 1 (which shouldn't happen since we bail out at |z| > 2), log2(log2(|z|)) could be negative or undefined. But since we bail out at |z| > 2, log2(|z|) > 1, so log2(log2(|z|)) > 0. So we're fine.

Another edge case: if |z| is exactly 2 (which shouldn't happen with floating point), log2(log2(2)) = log2(1) = 0. So mu = iter + 1. That's fine.

OK, I'm confident the math is correct. Let me write the code.

Let me also add a subtle enhancement: for the interior of the set (points that don't escape), I'll color them black. But I could also color them based on the iteration count at which they "almost" escape, or use some other coloring. For simplicity, I'll keep the interior black.

Actually, for a more interesting look, I could color the interior with a very dark color or use the "distance estimate" to color it. But black is the classic choice and looks clean.

Let me finalize and write the code.

Wait, one more thing. For the wheel zoom, I should make the zoom factor reasonable. A factor of 1.15 per scroll tick is good. But some mice have very sensitive wheels, so I should handle that. The deltaY can be large for a single tick. Let me use a continuous zoom factor based on deltaY:

```javascript
const factor = Math.exp(-e.deltaY * 0.001);
```

This maps the scroll amount to a zoom factor. For deltaY = 100 (typical scroll), factor = exp(-0.1) ≈ 0.905 (zoom out). For deltaY = -100, factor = exp(0.1) ≈ 1.105 (zoom in).

Actually, let me use a slightly different scaling: `Math.exp(-e.deltaY * 0.0015)` which gives about 1.16 per 100 units of scroll. That's a good zoom speed.

Or I could just use a fixed factor per tick. Let me use the continuous approach for smoothness.

OK, let me write the final code now. Here it is:

Actually wait - I want to make sure the smooth coloring looks good. Let me reconsider the palette and the mapping.

The issue with mapping mu through log(1+mu)/log(1+maxIter) is that for small mu (near the boundary), the colors are clustered near the start of the palette. For deep zooms where maxIter is large, most mu values will be much smaller than maxIter, so the colors will be in the first part of the palette.

A better approach: use a cyclic palette and map mu modulo the palette size. This way, the colors cycle as mu increases, and you always see the full range of colors regardless of zoom level.

Let me use this approach:

1. Build a cyclic palette of 256 colors (designed so that the first and last colors are similar)
2. For each pixel with smooth value mu:
   - t = mu % 1 (fractional part for interpolation)
   - i = floor(mu) % 256 (integer part for palette index)
   - Interpolate between palette[i] and palette[(i+1) % 256] using t

For the cyclic palette, I'll use a sine-based approach or a carefully designed gradient.

Let me use a sine-based palette with a specific hue rotation:

```javascript
function cyclicPaletteColor(t) {
    // t in [0, 1], cycles
    const hue = t * 360; // 0 to 360 degrees
    // Use HSL to RGB conversion with specific saturation and lightness
    // But let me just use sine waves
    const r = Math.sin(2 * Math.PI * t * 1.0 + 0.00) * 0.5 + 0.5;
    const g = Math.sin(2 * Math.PI * t * 1.0 + 2.094) * 0.5 + 0.5;
    const b = Math.sin(2 * Math.PI * t * 1.0 + 4.189) * 0.5 + 0.5;
    return [r, g, b];
}
```

This gives a pure rainbow cycle. But I want something more artistic. Let me use a palette with specific color stops that's designed to be cyclic.

Actually, let me try a different approach. I'll use a palette that goes through this cycle:
dark blue → cyan → green → yellow → red → magenta → dark blue

This is a classic "heat map" style cycle but with blue instead of black at the ends.

```javascript
const stops = [
    { t: 0.00, r: 0.00, g: 0.00, b: 0.10 },  // dark blue
    { t: 0.15, r: 0.00, g: 0.20, b: 0.50 },  // medium blue
    { t: 0.30, r: 0.00, g: 0.50, b: 0.60 },  // cyan
    { t: 0.45, r: 0.20, g: 0.70, b: 0.40 },  // green
    { t: 0.55, r: 0.60, g: 0.80, b: 0.20 },  // yellow-green
    { t: 0.65, r: 0.90, g: 0.70, b: 0.10 },  // yellow
    { t: 0.75, r: 0.95, g: 0.40, b: 0.10 },  // orange
    { t: 0.85, r: 0.80, g: 0.10, b: 0.10 },  // red
    { t: 0.95, r: 0.50, g: 0.00, b: 0.30 },  // magenta
    { t: 1.00, r: 0.00, g: 0.00, b: 0.10 },  // back to dark blue (cyclic)
];
```

This is cyclic (first and last are the same). The interpolation between stops will create a smooth gradient.

But wait, going from red (0.80, 0.10, 0.10) to magenta (0.50, 0.00, 0.30) to dark blue (0.00, 0.00, 0.10) - the transition from red to dark blue goes through magenta, which is fine.

Actually, the transition from the last stop (magenta, t=0.95) to the first stop (dark blue, t=0.00/1.00) needs to be smooth. Since the palette is cyclic, when we wrap around from t=1.00 to t=0.00, the colors should match. Since I set both to (0.00, 0.00, 0.10), they match perfectly.

But the interpolation between t=0.95 (magenta) and t=1.00 (dark blue) goes from (0.50, 0.00, 0.30) to (0.00, 0.00, 0.10), which is a smooth transition to dark blue. And the interpolation between t=0.00 (dark blue) and t=0.15 (medium blue) goes from (0.00, 0.00, 0.10) to (0.00, 0.20, 0.50), which is also smooth.

This should work well. Let me use this palette.

For the palette indexing with smooth coloring:

```javascript
const mu = iter + 1 - nu; // smooth iteration count
const idx_f = mu % 256; // fractional index in [0, 256)
const idx_i = Math.floor(idx_f); // integer part
const frac = idx_f - idx_i; // fractional part
const i0 = idx_i % 256;
const i1 = (idx_i + 1) % 256;
// interpolate between palette[i0] and palette[i1]
```

Wait, I need to be more careful. The smooth value mu can be very large (up to maxIter, which can be 1000). I want to use mu to index into a 256-color cyclic palette.

mu % 256 gives a value in [0, 256). Then:
- i0 = floor(mu % 256) → in [0, 255]
- frac = (mu % 256) - i0 → in [0, 1)
- i1 = (i0 + 1) % 256

Interpolation: color = palette[i0] * (1 - frac) + palette[i1] * frac

This gives smooth cycling through the palette.

For the interior of the set (mu < 0), I'll color it black.

Let me finalize this approach and write the code.

Actually, I realize there might be an issue with the smooth value being negative for interior points. I handle that with the `if (iter >= maxIter)` check, setting the color to black. For exterior points, mu is always positive (since iter >= 0 and nu < iter + 1 for escaped points).

Wait, is nu always less than iter + 1? Let me check:
- nu = log2(log2(|z|))
- At bailout, |z| > 2, so log2(|z|) > 1, so nu > 0
- For points that escape early (|z| just above 2), log2(|z|) ≈ 1, so nu ≈ 0
- For points that escape late (|z| very large), log2(|z|) is large, so nu is large
- But nu can be larger than iter + 1 for points that escape very late

Wait, can nu be greater than iter + 1? Let me think...
- nu = log2(log2(|z|))
- For |z| = 2^

Let me create a full-screen Mandelbrot set explorer with smooth coloring, pan, and zoom.

write /tmp/ws/index.html (10051 bytes)
toolResult

Successfully wrote 10051 bytes to /tmp/ws/index.html

assistant
thinking
Let me review what I built:

1. **Full-screen canvas** - fills the viewport, resizes with window
2. **Mandelbrot set** - iterates z = z² + c, with smooth iteration counting (normalized fractional iteration count using log scaling)
3. **Smooth color gradient** - 256-entry cyclic palette with smooth interpolation between adjacent palette entries, using log(1+mu)/log(1+maxIter) to map mu [0, maxIter] into [0,1) for the palette
4. **Click-and-drag to pan** - mouse drag, touch drag, mouse cursor changes
5. **Mouse-wheel to zoom** - zooms towards cursor position
6. **Touch support** - pinch-to-zoom and single-finger drag
7. **Keyboard shortcuts** - R to reset, +/- to zoom in/out at center

The file is at /tmp/ws/index.html. Done.

The file is at `/tmp/ws/index.html`. Here's what it does: **Mandelbrot set** rendered with smooth coloring: - Smooth iteration count via `mu = iter + 1 − log(log(|z|))/log(2)` gives sub-pixel continuity - 256-entry cyclic gradient palette (deep blue → teal → green → yellow → orange → red → magenta → blue), with sub-pixel linear interpolation between palette stops - Log-scale mapping so the palette is evenly utilized at any depth **Interaction:** - **Click + drag** — pan the view (cursor changes to grab/grabbing) - **Mouse wheel** — zoom toward cursor (1.16× per ~100 delta units) - **Touch** — single-finger drag to pan, pinch-to-zoom toward midpoint - **Keyboard** — `R` resets, `+/−` zoom in/out at center - **Resize** — adapts to new window size (debounced) **Adaptive iterations:** `maxIter` grows with zoom depth (60–2000) so deeper zooms still resolve fine structure. Open `index.html` in a browser — it's fully self-contained, no external assets.

TAKE IT TO CODE

A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.

RUN

modelsolar-pro4
vendorUpstage
harnesspi-agent
generated2026-08-13

EVALUATION

4/5 working

Smooth-colored Mandelbrot renders correctly; drag pans and wheel zooms work well, keyboard input has no effect as expected.

Vision grade · anthropic/claude-sonnet-5

MEASUREMENTS

Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.

idle motion0.3%
on click0.0%
on drag89.7%
on wheel17.2%
on enter + space0.0%
on arrow keys0.0%
on w/a/s/d0.0%
frame spread36 / 255
console errors0
js errors none

METRICS

runtime259.5s
tokens in6.6k
tokens out20k
cached0
cost / run$0.0026