Mandelbrot explorer · seed-2.0-mini
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.
transcript · 4 messages
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.
thinking
Got it, let's tackle this. The user wants a full-screen Mandelbrot set app with pan and zoom, all in one index.html file. First, I need to remember how Mandelbrot works: for each pixel, iterate z = z² + c, check if it escapes, use the number of iterations for color, smooth the coloring with the log log trick.
First, structure the HTML: a canvas element that's full screen, no margins, style it to take 100vw/vh, body overflow hidden so no scrollbars. Then the JS will handle the canvas, the Mandelbrot rendering, mouse events for pan and zoom.
Wait, first, set up the canvas: get the context, set its width and height to the window's innerWidth and innerHeight, but handle resizing too. Then the Mandelbrot parameters: initial viewport, like center at (-0.5, 0), scale (zoom level) initially 2.0 so the whole set is visible.
Then the smooth coloring: the standard formula is smooth_color = iter_count - log(log(|z|))/log(2), that gives the fractional iterations for a smoother gradient instead of banding.
Mouse events: click and drag to pan. So track mouse down: when the user presses the mouse button, record the start position and the current viewport center. Then on mouse move, calculate the delta, convert screen pixels to complex plane coordinates, update the center. Mouse up stops the pan.
Mouse wheel zoom: when the user scrolls, zoom in/out around the mouse position. So get the mouse position relative to the canvas, convert that to complex coordinates, then adjust the scale, then recenter so that the mouse position stays at the same complex point—so that zooming is around the cursor, which is standard.
Also, need to debounce or throttle the rendering? Wait, but for Mandelbrot, rendering each frame on pan/zoom might be slow, but since it's a single canvas, maybe use an offscreen canvas or just render on demand when the view changes. Wait, but for a simple app, even rendering the whole set each time is okay for small screens, but maybe optimize a bit? No, the user just wants a working version, so let's keep it simple first.
Wait, let's outline the steps:
1. HTML structure:
- <!DOCTYPE html>
- <html>
- <head> with <style> that sets body margin 0, overflow hidden, canvas full width/height.
- <body> with <canvas id="mandelbrotCanvas"></canvas>
- <script> tag with all the JS.
2. CSS:
body { margin: 0; overflow: hidden; }
canvas { display: block; width: 100vw; height: 100vh; }
3. JS variables:
- canvas: getElementById
- ctx: canvas.getContext('2d')
- width, height: window.innerWidth, innerHeight, set on resize and init
- view: { centerX: -0.5, centerY: 0, scale: 2.0 } — initial view, scale is the half-width of the viewport (so total width is 2*scale, since from centerX - scale to centerX + scale)
- maxIter: 200 — initial max iterations, can adjust, but 200 is okay for start
- isPanning: false, startMouse: { x: 0, y: 0 }, startViewCenter: { x: 0, y: 0 } — for pan tracking
4. Functions:
- pixelToComplex(x, y): converts canvas pixel coordinates to complex plane c = (re, im)
re = view.centerX - view.scale + (x / width) * 2 * view.scale
im = view.centerY - view.scale + (y / height) * 2 * view.scale
Wait, wait, no: wait, y increases downward on canvas, but complex plane y increases upward, so we need to flip the y axis. Oh right! So im = view.centerY + view.scale - (y / height) * 2 * view.scale. Because for y=0 (top of canvas), im is centerY + scale, and y=height (bottom) is centerY - scale. That's important, otherwise the Mandelbrot will be flipped vertically.
- complexToPixel(re, im): inverse of above, for zooming around mouse position.
- renderMandelbrot(): loops over each pixel, calculates the iteration count, applies smooth coloring, draws to canvas.
For each pixel (x,y):
get c = pixelToComplex(x,y)
initialize zx=0, zy=0, iteration=0
while zx² + zy² < 4 and iteration < maxIter:
temp = zx² - zy² + c.re
zy = 2*zx*zy + c.im
zx = temp
iteration++
then smooth coloring: if iteration < maxIter, then smooth = iteration + 1 - Math.log(Math.log(zx²+zy²))/Math.log(2); else smooth = maxIter
then map smooth to a color, using HSL color wheel, since that gives a nice gradient. So hue = (smooth / maxIter) * 360, saturation 100%, lightness 50% — wait, but HSL is easier for gradients, or we can use a linear gradient, but HSL is fine. Wait, but for the smooth color, we can use that hue value. Also, if iteration is maxIter, set color to black (or the background, but black is okay for the set itself).
Wait, but the log log trick: yes, that's to avoid banding. So that's correct.
5. Mouse event handlers:
- mousedown: set isPanning to true, record startMouse (clientX, clientY) converted to canvas coordinates, record the current view center.
- mousemove: if isPanning, calculate deltaX and deltaY, convert those to complex plane delta, update view.centerX and centerY. Then call renderMandelbrot.
- mouseup: set isPanning to false.
- mouseleave: also set isPanning to false, in case the user drags off the canvas.
- wheel: handle zooming. Prevent default so the page doesn't scroll. Get the mouse position relative to canvas, convert that to complex coordinates (mouseRe, mouseIm). Then adjust the scale: if deltaY > 0, zoom out (scale *= 1.1), else zoom in (scale /= 1.1). Then, we need to keep the mouse position at the same complex point, so adjust the center: newCenterX = mouseRe - (mouseXInCanvas / width) * 2 * newScale, wait no: wait, after scaling, the mouse position's complex coordinates should stay the same. Let's see: original scale is s, new scale is s' = s * factor (factor is 0.9 for zoom in, 1.1 for zoom out). The mouse position on canvas is (mx, my), which corresponds to complex (cr, ci) = pixelToComplex(mx, my). After zooming, the new scale is s', so the new center should be cr - (mx / width)*2*s' ? No, wait, let's think: the original viewport from x=0 to x=width is cr_left = centerX - s to centerX + s. The mouse pixel mx is at (mx/width)*2*s from the left, so complex position is centerX - s + (mx/width)*2*s = centerX + s*(2*(mx/width) -1). We want that complex position to stay the same after zooming. So after zooming, centerX' - s' + (mx/width)*2*s' = same as before. So centerX' = (original complex position) + s'*(1 - 2*(mx/width))? Wait no, let's solve:
Original complex at mouse: C = view.centerX + view.scale*(2*(mx/width) - 1) for x, same for y but flipped. After zooming to scale s', we need C = newCenterX + s'*(2*(mx/width) -1). So newCenterX = C - s'*(2*(mx/width) -1). Which is exactly the original centerX plus (view.scale - s')*(2*(mx/width)-1). Oh right, that's a simpler way: adjust the center so that the mouse point stays at the same complex coordinate. So same for Y axis, but flipped because of the canvas y direction.
So in the wheel handler:
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// convert mx, my to complex coordinates before zoom? Wait no, wait we want to zoom around the mouse position, so the complex point under the mouse before zoom is the one we keep fixed.
const [cr, ci] = pixelToComplex(mx, my);
const factor = e.deltaY > 0 ? 1.1 : 0.9; // zoom out on positive delta, in on negative
const newScale = view.scale * factor;
// calculate new center X and Y:
view.centerX = cr - newScale*(2*(mx/width)-1);
// for Y: since canvas y is flipped, the complex im is ci = view.centerY + view.scale*(1 - 2*(my/height)) — wait wait, earlier I had im = view.centerY + view.scale - (my/height)*2*view.scale, which is equal to view.centerY + view.scale*(1 - 2*(my/height)). Yes, correct. So when we change scale to newScale, the new centerY is ci - newScale*(1 - 2*(my/height)).
view.centerY = ci - newScale*(1 - 2*(my/height));
view.scale = newScale;
renderMandelbrot();
That's correct for zooming around the mouse position.
6. Resize handler: when the window resizes, update the canvas width and height to innerWidth and innerHeight, then re-render the Mandelbrot set. Because if we don't, the canvas will stretch, and the pixels will be wrong. So add a window.addEventListener('resize', () => { width = window.innerWidth; height = window.innerHeight; canvas.width = width; canvas.height = height; renderMandelbrot(); });
7. Initialization: set canvas.width and height to window inner width/height, add all the event listeners, then call renderMandelbrot() once to draw the initial set.
Wait, also, for the color: using HSL is good, but let's make sure that when iteration is maxIter, we set color to black, else use the smooth hue. So:
let color;
if (iteration === maxIter) {
color = '#000';
} else {
const normalized = smooth / maxIter;
const hue = normalized * 360;
color = `hsl(${hue}, 100%, 50%)`;
}
Wait, but HSL(360, 100%, 50%) is red, same as HSL(0, 100%, 50%), so that's okay, the gradient wraps around. Alternatively, we can use a different color scheme, but this is simple and looks good.
Wait, but let's test the pixelToComplex function again. Let's say x is 0 (left edge of canvas): 2*(0/width) -1 = -1, so centerX + scale*(-1) = centerX - scale, which is correct. x is width/2: 2*(0.5) -1 =0, so centerX, correct. x is width: 2*(1)-1=1, centerX + scale, correct. For Y: my=0 (top of canvas): 1 -2*(0/height) =1, so centerY + scale*1 = centerY + scale, which is the top of the complex plane, correct. my=height/2: 1-2*(0.5)=0, centerY, correct. my=height: 1-2*(1) =-1, centerY - scale, correct. And since earlier im = view.centerY + view.scale*(1 - 2*(my/height)), yes that's right, so flipping the Y axis so that the top of the canvas is the positive imaginary axis, which is what we want.
Wait another thing: the Mandelbrot set is usually plotted with real axis from -2 to 1, imaginary from -1.5 to 1.5, which is total width 3, but our initial scale is 2.0, so centerX is -0.5, so centerX - scale = -0.5 -2 = -2.5, centerX + scale = -0.5 +2=1.5, which is exactly the standard range, perfect! Oh right, that's why the initial center is (-0.5, 0) and scale 2.0, that shows the entire Mandelbrot set correctly. Great, that's a good starting point.
Now, let's put all this together into a single index.html file.
Wait, also, when rendering, looping over every pixel with nested for loops (x from 0 to width, y from 0 to height) could be slow on large screens, but for a demo it's okay. Let's see, for a 1920x1080 screen, that's ~2 million pixels, each with up to 200 iterations, that's 400 million operations, which might be a bit slow, but modern browsers can handle that, especially if we don't overdo it. Alternatively, we can use ImageData to set pixels directly, which is faster than calling fillRect for each pixel. Oh right! That's a better approach: create an ImageData object, loop through each pixel, set the RGBA values, then putImageData once at the end. That's way faster than drawing each pixel individually.
Ah, right, that's an important optimization. So instead of ctx.fillStyle and fillRect, we can do:
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
Then for each x and y:
const pixelIndex = (y * width + x) *4;
then calculate the iteration, smooth, then set data[pixelIndex] = r, data[pixelIndex+1] = g, data[pixelIndex+2] = b, data[pixelIndex+3] = 255.
Then convert HSL to RGB, because ImageData uses RGBA values 0-255. Oh, right, I forgot that HSL to RGB conversion is needed if we use ImageData, because the browser's fillStyle with HSL works, but putImageData is faster if we precompute the RGB values. So let's add an HSL to RGB converter function.
Yes, that's better for performance. Let's write a function hslToRgb(h, s, l) where h is 0-360, s 0-1, l 0-1, returns [r, g, b] 0-255.
How to convert HSL to RGB:
The standard formula:
1. Convert h to a value between 0 and 6: k = h /60
2. Let h = k - floor(k)
3. Let p = l*(1 -s)
4. let q = l*(1 - s*h)
5. let t = l*(1 - s*(1 -h))
6. Then depending on which sector k is in:
0-1: [l, t, p]
1-2: [q, l, p]
2-3: [p, l, t]
3-4: [p, q, l]
4-5: [t, p, l]
5-6: [l, p, q]
Then multiply each by 255 to get 0-255 values.
Yes, that's the standard conversion. Let's code that function.
So putting that all together:
function hslToRgb(h, s, l) {
const c = (1 - Math.abs(2*l -1)) * s;
const x = c * (1 - Math.abs((h /60) %2 -1));
const m = l - c/2;
let r, g, b;
if (h >=0 && h <60) { [r,g,b] = [c,x,0]; }
else if (h <120) { [r,g,b] = [x,c,0]; }
else if (h <180) { [r,g,b] = [0,c,x]; }
else if (h <240) { [r,g,b] = [0,x,c]; }
else if (h <300) { [r,g,b] = [x,0,c]; }
else { [r,g,b] = [c,0,x]; }
return [
Math.round((r + m)*255),
Math.round((g + m)*255),
Math.round((b + m)*255)
];
}
That's a simpler way than the sector method, maybe easier to code correctly.
Then, in the render loop:
function renderMandelbrot() {
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const scale = view.scale;
const cx = view.centerX;
const cy = view.centerY;
const maxI = maxIter;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Calculate complex c for this pixel
const re = cx + scale*(2*(x/width) -1);
const im = cy + scale*(1 - 2*(y/height));
let zx = 0, zy =0;
let iter =0;
while (zx*zx + zy*zy <4 && iter < maxI) {
const temp = zx*zx - zy*zy + re;
zy = 2*zx*zy + im;
zx = temp;
iter++;
}
const pixelIdx = (y * width + x)*4;
if (iter === maxI) {
// Black for inside the set
data[pixelIdx] = 0;
data[pixelIdx+1] =0;
data[pixelIdx+2] =0;
data[pixelIdx+3] =255;
} else {
// Smooth coloring
const zn = zx*zx + zy*zy;
const smooth = iter + 1 - Math.log(Math.log(zn))/Math.log(2);
const normalized = smooth / maxI;
const hue = normalized * 360;
const [r,g,b] = hslToRgb(hue, 1, 0.5);
data[pixelIdx] = r;
data[pixelIdx+1] =g;
data[pixelIdx+2] =b;
data[pixelIdx+3] =255;
}
}
}
ctx.putImageData(imageData, 0,0);
}
That's way faster than drawing each pixel with fillRect. Great.
Now, the mouse events: let's add them.
First, mousedown:
canvas.addEventListener('mousedown', (e) => {
isPanning = true;
const rect = canvas.getBoundingClientRect();
startMouse.x = e.clientX - rect.left;
startMouse.y = e.clientY - rect.top;
startViewCenter.x = view.centerX;
startViewCenter.y = view.centerY;
});
Then mousemove:
canvas.addEventListener('mousemove', (e) => {
if (!isPanning) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const deltaX = currentX - startMouse.x;
const deltaY = currentY - startMouse.y;
// Convert pixel delta to complex delta
const complexDeltaX = -2 * view.scale * deltaX / width;
const complexDeltaY = 2 * view.scale * deltaY / height;
view.centerX = startViewCenter.x + complexDeltaX;
view.centerY = startViewCenter.y + complexDeltaY;
renderMandelbrot();
});
Wait, wait, let's see: deltaX is the number of pixels the mouse moved right. So the view should shift left by that amount? Wait, if the user drags the mouse to the right, the image should move left, so the centerX increases? No, wait no: if you drag the image right, the complex plane's center moves right? Wait no, let's think: if the mouse moves from x1 to x2 =x1 + dx (right), then the pixel position that was at x1 is now at x2, so to keep the same complex point under the mouse, the center should shift by -dx * (2*scale / width). Oh right, because earlier, the complex delta for x is (currentX - startMouse.x) * (-2*scale / width). Wait let's take an example: if dx is positive (mouse moved right), then complexDeltaX is negative, so centerX = startCenterX + (-dx*2scale/width). Which means the center moves left, which is correct: dragging the image right moves the view left, so the content shifts right relative to the window. Yes, that's right.
Wait let's test: startMouse.x is 100, currentX is 200, dx=100. width is 1000, scale is 2. So complexDeltaX = -2*2*100 /1000 = -400/1000 = -0.4. So centerX increases by -0.4? No, wait startViewCenter.x is the original center, so view.centerX = startViewCenter.x + (-0.4) — wait no, wait the code says:
const complexDeltaX = -2 * view.scale * deltaX / width;
view.centerX = startViewCenter.x + complexDeltaX;
Yes, so deltaX is currentX - startMouse.x, so if deltaX is positive (mouse moved right), complexDeltaX is negative, so centerX decreases? Wait no, wait let's take the initial startViewCenter.x is -0.5, scale 2, width 1000. deltaX is 100 (mouse moved right 100 pixels). Then complexDeltaX is -2*2*100 /1000 = -0.4. So view.centerX = -0.5 + (-0.4) = -0.9? That would move the center left, which makes the image shift right, which is correct. Because if you drag the image to the right, the part that was on the left of the screen moves to the right, so the center's real part decreases? Wait no, wait let's say the center is at -0.5, scale 2, so the left edge is -2.5, right edge 1.5. If we move the center to -0.9, then left edge is -0.9 -2 = -2.9, right edge is -0.9 +2=1.1. So the entire image has shifted left, so the content appears to shift right relative to the window, which is exactly what happens when you drag the mouse to the right. Yes, that's correct.
And for the Y axis: deltaY is currentY - startMouse.y, positive means mouse moved down. complexDeltaY is 2*view.scale*deltaY / width? Wait wait no, in the code above, for Y it's 2*view.scale*deltaY / height? Wait wait the code says:
const complexDeltaY = 2 * view.scale * deltaY / height;
view.centerY = startViewCenter.y + complexDeltaY;
Wait let's see: Y increases downward on the canvas. So if the user drags the mouse down (deltaY positive), then complexDeltaY is positive, so centerY increases. But the complex plane's Y axis increases upward, so centerY increasing means the imaginary part increases, so the image shifts up? Wait no, wait if you drag the mouse down, you want the image to shift down, so the content moves up relative to the window. Wait let's take an example: startViewCenter.y is 0, scale 2, height 1000. deltaY is 100 (mouse moved down 100 pixels). complexDeltaY is 2*2*100 /1000 = 0.4. So view.centerY =0 +0.4=0.4. So the new centerY is 0.4, so the top edge of the viewport is 0.4 +2=2.4, bottom edge is 0.4-2=-1.6. So the entire image has shifted up, which means the content appears to shift down relative to the window, which is correct. Because when you drag the mouse down, the part that was at the top of the screen moves down, so the content looks like it's moving down. Yes, that's right.
Wait wait earlier, the im for a pixel is cy + scale*(1-2y/height). So when y increases (moving down the canvas), im decreases. So the complex delta for Y: when the mouse moves down (deltaY positive), the pixel's y increases, so the complex im for that pixel decreases. So to get the correct shift, yes, the complexDeltaY is 2*scale*deltaY/height, which makes the centerY increase when deltaY is positive, which is correct. Okay, that's right.
Then mouseup and mouseleave:
canvas.addEventListener('mouseup', () => {
isPanning = false;
});
canvas.addEventListener('mouseleave', () => {
isPanning = false;
});
Then the wheel event:
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Get complex coordinates under the mouse before zoom
const cr = view.centerX + view.scale*(2*(mx/width)-1);
const ci = view.centerY + view.scale*(1 - 2*(my/height));
const factor = e.deltaY >0 ? 1.1 : 0.9; // zoom out if deltaY positive, in otherwise
const newScale = view.scale * factor;
// Calculate new center so that (mx,my) maps to (cr,ci) still
view.centerX = cr - newScale*(2*(mx/width)-1);
view.centerY = ci - newScale*(1 - 2*(my/height));
view.scale = newScale;
renderMandelbrot();
});
Perfect, that's the correct zoom handling.
Then the resize event:
window.addEventListener('resize', () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
renderMandelbrot();
});
Now, initial setup:
let canvas = document.getElementById('mandelbrotCanvas');
let ctx = canvas.getContext('2d');
let width = window.innerWidth;
let height = window.innerHeight;
canvas.width = width;
canvas.height = height;
let view = {
centerX: -0.5,
centerY: 0,
scale: 2.0
};
let maxIter = 200;
let isPanning = false;
let startMouse = {x:0, y:0};
let startViewCenter = {x:0, y:0};
Then call renderMandelbrot() once at the end to draw the initial set.
Wait, also, let's add a loading message? No, the render is fast enough, but maybe not necessary. Also, the maxIter: 200 is okay, but when zooming in, we can increase the maxIter to get more detail, but that's optional. For the demo, 200 is fine, but maybe we can adjust it based on scale? Like, when scale is less than 0.1, increase maxIter to 500, but that's an optimization, not required for the basic version.
Now, let's put all this together into the index.html file. Let's write the full code:
First, the HTML doctype, then html, head with style, then body with canvas, then script with all the code.
Wait, let's check for any missing parts:
- The hslToRgb function is included.
- All event listeners are added.
- The render function uses ImageData for speed.
- The pan and zoom work correctly.
- The canvas is full screen, no scrollbars.
Wait, also, in the CSS, body has overflow: hidden, which prevents scrollbars when the canvas is full size. Correct.
Let's write the full code:
<!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>
body {
margin: 0;
overflow: hidden;
background: black;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
}
</style>
</head>
<body>
<canvas id="mandelbrotCanvas"></canvas>
<script>
const canvas = document.getElementById('mandelbrotCanvas');
const ctx = canvas.getContext('2d');
let width = window.innerWidth;
let height = window.innerHeight;
canvas.width = width;
canvas.height = height;
// Mandelbrot view parameters
let view = {
centerX: -0.5,
centerY: 0,
scale: 2.0
};
const maxIter = 200;
let isPanning = false;
const startMouse = { x: 0, y: 0 };
const startViewCenter = { x: 0, y: 0 };
// HSL to RGB conversion
function hslToRgb(h, s, l) {
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
const m = l - c / 2;
let r, g, b;
if (h >= 0 && h < 60) { [r, g, b] = [c, x, 0]; }
else if (h < 120) { [r, g, b] = [x, c, 0]; }
else if (h < 180) { [r, g, b] = [0, c, x]; }
else if (h < 240) { [r, g, b] = [0, x, c]; }
else if (h < 300) { [r, g, b] = [x, 0, c]; }
else { [r, g, b] = [c, 0, x]; }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255)
];
}
// Render the Mandelbrot set
function renderMandelbrot() {
const imageData = ctx.createImageData(width, height);
const data = imageData.data;
const { centerX, centerY, scale } = view;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Convert pixel to complex plane coordinates
const re = centerX + scale * (2 * (x / width) - 1);
const im = centerY + scale * (1 - 2 * (y / height));
let zx = 0, zy = 0;
let iter = 0;
while (zx * zx + zy * zy < 4 && iter < maxIter) {
const temp = zx * zx - zy * zy + re;
zy = 2 * zx * zy + im;
zx = temp;
iter++;
}
const pixelIdx = (y * width + x) * 4;
if (iter === maxIter) {
// Inside the Mandelbrot set: black
data[pixelIdx] = 0;
data[pixelIdx + 1] = 0;
data[pixelIdx + 2] = 0;
data[pixelIdx + 3] = 255;
} else {
// Smooth coloring using log(log(zn)) trick
const zn = zx * zx + zy * zy;
const smooth = iter + 1 - Math.log(Math.log(zn)) / Math.log(2);
const normalized = smooth / maxIter;
const hue = normalized * 360;
const [r, g, b] = hslToRgb(hue, 1, 0.5);
data[pixelIdx] = r;
data[pixelIdx + 1] = g;
data[pixelIdx + 2] = b;
data[pixelIdx + 3] = 255;
}
}
}
ctx.putImageData(imageData, 0, 0);
}
// Mouse pan handlers
canvas.addEventListener('mousedown', (e) => {
isPanning = true;
const rect = canvas.getBoundingClientRect();
startMouse.x = e.clientX - rect.left;
startMouse.y = e.clientY - rect.top;
startViewCenter.x = view.centerX;
startViewCenter.y = view.centerY;
});
canvas.addEventListener('mousemove', (e) => {
if (!isPanning) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const deltaX = currentX - startMouse.x;
const deltaY = currentY - startMouse.y;
// Convert pixel delta to complex plane delta
const complexDeltaX = -2 * view.scale * deltaX / width;
const complexDeltaY = 2 * view.scale * deltaY / height;
view.centerX = startViewCenter.x + complexDeltaX;
view.centerY = startViewCenter.y + complexDeltaY;
renderMandelbrot();
});
canvas.addEventListener('mouseup', () => isPanning = false);
canvas.addEventListener('mouseleave', () => isPanning = false);
// Mouse wheel zoom handler
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Complex coordinates under the mouse before zoom
const cr = view.centerX + view.scale * (2 * (mx / width) - 1);
const ci = view.centerY + view.scale * (1 - 2 * (my / height));
// Adjust zoom factor
const zoomFactor = e.deltaY > 0 ? 1.1 : 0.9;
const newScale = view.scale * zoomFactor;
// Update center to keep mouse position fixed
view.centerX = cr - newScale * (2 * (mx / width) - 1);
view.centerY = ci - newScale * (1 - 2 * (my / height));
view.scale = newScale;
renderMandelbrot();
});
// Window resize handler
window.addEventListener('resize', () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
renderMandelbrot();
});
// Initial render
renderMandelbrot();
</script>
</body>
</html>
Wait, that's the full code. Let's check:
- All CSS is in the style tag, no external resources.
- All JS is in the script tag, no external files.
- The canvas is full screen, no scrollbars.
- Click and drag to pan, mouse wheel to zoom.
- Smooth coloring with the log log trick.
- HSL to RGB conversion for the colors.
- Uses ImageData for fast rendering.
- Handles window resizing correctly.
Yes, that should work. Now, the user wants this written into index.html in the current directory, so I need to use the write function to create this file./tmp/ws/index.html (6270 bytes)Successfully wrote 6270 bytes to /tmp/ws/index.html
thinking
Great, the file was written successfully. Now I should confirm that the app is ready, and explain how to use it: open index.html in a browser, click and drag to pan, mouse wheel to zoom, full screen, smooth colors. Let's make sure to mention that it's all self-contained, no external resources, runs sandboxed.
Created a fully self-contained Mandelbrot set viewer at `/tmp/ws/index.html`. ### Features: 1. Full-screen canvas rendering with smooth gradient coloring using the log-log iteration trick 2. **Click-and-drag** to pan around the set 3. **Mouse wheel** to zoom in/out centered on your cursor 4. Responsive: automatically resizes with your window 5. No external resources/CDNs: runs completely sandboxed locally To use it, just open `index.html` in any modern web browser!
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
EVALUATION
Mandelbrot renders with drag pan and wheel zoom working, but color gradient is a harsh flat red/yellow/green rather than smooth.
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.