oneshotlm
Compare all models on this prompt →

Mandelbrot explorer · minimax-m3

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 · 6 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 Mandelbrot set renderer in a single self-contained index.html file. Let me think about what makes this great:

1. **Performance**: For smooth interactive exploration, I need to use Canvas with WebGL or at least an efficient 2D canvas approach. WebGL would be ideal for performance.

2. **Smooth coloring**: Use the continuous escape time algorithm so we get smooth gradients instead of banded iterations.

3. **Interactive controls**: Click and drag to pan, scroll to zoom centered on mouse position.

4. **Visual appeal**: A nice color palette - probably use a palette like HSV cycling, or a custom sine-based palette that looks like the classic fractal images.

5. **UI/HUD**: Show coordinates, zoom level, iteration count. Maybe a reset button.

Let me build a WebGL-based Mandelbrot renderer. WebGL is the way to go for performance - we can render at 60fps even with high iteration counts.

I'll use a fragment shader that:
- Maps pixel coords to complex plane
- Iterates z = z² + c with smooth coloring
- Outputs color based on escape value

For the smooth coloring, I'll use the standard technique:
- For points that don't escape: color = 0 (black)
- For points that escape: log/log gives smooth value, multiplied by some factor to cycle through colors

Let me write this out properly.

Structure:
- HTML: canvas + minimal HUD overlay
- CSS: full-screen, dark theme, styled HUD
- JS: WebGL setup, shader compilation, render loop, mouse interaction

Let me think about the shader:

```glsl
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_center;  // complex center in mandelbrot coords
uniform float u_zoom;   // zoom (pixels per unit or scale factor)
uniform int u_maxIter;
uniform float u_paletteOffset;

// Color palette function
vec3 palette(float t) {
    // Nice cosine-based palette
    vec3 a = vec3(0.5, 0.5, 0.5);
    vec3 b = vec3(0.5, 0.5, 0.5);
    vec3 c = vec3(1.0, 1.0, 1.0);
    vec3 d = vec3(0.0, 0.10, 0.20);
    return a + b * cos(6.28318 * (c * t + d));
}

void main() {
    vec2 c = (gl_FragCoord.xy - 0.5 * u_resolution) / u_zoom + u_center;
    vec2 z = vec2(0.0);
    
    float iter = 0.0;
    float maxIter = float(u_maxIter);
    for (int i = 0; i < 10000; i++) {
        if (float(i) >= maxIter) break;
        z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
        if (dot(z, z) > 256.0) break;
        iter += 1.0;
    }
    
    if (iter >= maxIter) {
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
        return;
    }
    
    // Smooth coloring
    float smooth = iter + 1.0 - log(log(length(z))) / log(2.0);
    float t = smooth / maxIter * 8.0 + u_paletteOffset;
    
    vec3 col = palette(t);
    gl_FragColor = vec4(col, 1.0);
}
```

Wait, maxIter needs to be passed somehow. Looping with a fixed integer that I can change at runtime in WebGL 1 is tricky. WebGL 1 requires constant loop bounds. I'll set a fixed upper bound and use a uniform comparison.

Actually, let me use WebGL 1 with GLSL ES 1.0 - that requires constant loop bounds. I can do:

```glsl
const int MAX_LOOP = 2000;
for (int i = 0; i < MAX_LOOP; i++) {
    if (i >= u_maxIter) break;
    ...
}
```

Hmm, but `if (i >= u_maxIter) break;` inside a for loop... actually this works since we have a constant bound. The compiler will unroll but with a break it should be ok in most implementations.

Let me just hardcode MAX_ITER to something reasonable like 1000 and skip the uniform for simplicity, OR pass maxIter as uniform with a check.

Actually a simpler approach: just set MAX_ITER constant in the shader (e.g., 1000), and allow the user to adjust this via JS that recompiles... no, that's clunky. Let me use the uniform with break.

The interaction math:
- Screen pixel (px, py) → complex coords cx = (px - W/2) / zoom + centerX, cy = (py - H/2) / zoom + centerY
- Or invert: cx = (px / W - 0.5) * scale + centerX where scale = W/zoom... let me just use the simple form.

For zoom, I'll define `zoom` as half-height of the view in mandelbrot units. When zoom = 1.5, we see y from centerY-1.5 to centerY+1.5, total height = 3.

Mouse wheel: zoom in/out, with new center adjusted so the point under the mouse stays put.
- mouse_x, mouse_y in pixels
- mouse in complex coords: c_mouse_old = ((mouse_x/W - 0.5) * W/zoom + centerX, ...)
- We want: c_mouse_new = c_mouse_old
- That means: centerX_new + (mouse_x/W - 0.5) * W/zoom_new = centerX + (mouse_x/W - 0.5) * W/zoom_old

Wait, let me reconsider. Let centerX, centerY be the complex coords shown at the center of the screen. Let zoom = number of pixels per mandelbrot unit. Then:
- pixel x=0 corresponds to complex x = centerX - W/2/zoom
- pixel x=W corresponds to complex x = centerX + W/2/zoom
- So complex_x = (pixelX - W/2) / zoom + centerX
- Similarly for y (note pixel coords typically y-down)

For wheel zoom:
- old: c_mouse = (mouseX - W/2)/zoom_old + centerX
- new: zoom_new = zoom_old * factor
- we want c_mouse new = c_mouse old
- (mouseX - W/2)/zoom_new + centerX_new = (mouseX - W/2)/zoom_old + centerX_old
- centerX_new = centerX_old + (mouseX - W/2) * (1/zoom_old - 1/zoom_new)

For pan on drag:
- deltaX pixels → deltaX/zoom in mandelbrot units → centerX decreases by deltaX/zoom (since we drag right, we expect view to follow, i.e., point that's now under mouse was previously at center - deltaX/zoom... wait)
- When user drags right by dragX pixels: the mandelbrot point that was at mouse position is now to the LEFT of mouse. So to bring it back to mouse, we need to shift the view LEFT. Hmm, actually that means centerX_new < centerX_old. Specifically: centerX_new = centerX_old - dragX/zoom.

Let me also add touch support for mobile - touch drag = pan, pinch = zoom.

UI:
- Top-left: coords, zoom level, iterations
- Reset button
- Maybe instructions

Let me also add:
- Double-click to reset
- Animate palette (small drift over time) - subtle, optional
- Or keep palette offset as a control

I'll add a simple control panel with: max iterations slider, palette cycle offset slider, reset button. Keep it clean.

Actually, let me keep it minimal but polished. Just show the HUD with coordinates and provide reset. Maybe iterations slider. Avoid clutter.

Let me start coding. I'll write it all in index.html.

WebGL setup considerations:
- Use `requestAnimationFrame` and only re-render when state changes (zoom/pan, or palette offset change)
- This is efficient - no continuous render needed for static views

Wait, actually if I want the palette to slowly animate, I need continuous rendering. But that's optional. Let me make it so we re-render on demand, but optionally have a small "animate palette" toggle.

Actually let me just make a single high-quality static render that updates on interaction. That's clean and elegant.

Or... I could have the palette slowly drift by default for visual interest. Let me do that - it's nice for the eye and easy.

Hmm, but constantly re-rendering at full resolution might cause flicker if the user doesn't want it. Let me add a subtle toggle "Auto-color" that defaults to off for performance and deterministic interaction. Or keep it on but slow. 

Compromise: add a button "🎨 Auto-shimmer" that when on, slowly cycles the palette. Default off.

Let me write the code now.

Oh and I should handle:
- WebGL context lost (maybe just ignore or log)
- Pixel ratio: use devicePixelRatio for crisp rendering on HiDPI displays, but cap it to avoid performance issues
- Resize handling: track and re-set viewport

Let me think about precision. With high zoom, we lose precision with float32. Could use a perturbation / arbitrary precision library, but that's a major undertaking. For "good enough" zoom, I can:
- Use `precision highp float` in shader
- Switch to double-float emulation or use the "split" technique

For a single-file demo with reasonable zoom, let's stay with highp float. This is fine up to maybe 10^5 or 10^6 zoom before pixelation becomes visible.

For really deep zoom we'd need perturbation theory (use reference orbits near Mandelbrot points). That's out of scope for a quick demo but I'll mention it in the UI comments. Actually, let me just keep it simple with float precision and trust the user.

Now let me think about the canvas resize. When window resizes, we need to update the resolution uniform and resize the canvas. The width/height in pixels should account for devicePixelRatio:

```js
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
```

And gl.viewport should be set to canvas.width, canvas.height.

But for performance, dpr of 2 on a 4K display = 16M pixels = many calculations. Let me cap dpr at 1.5 or 2.0 max.

Let me start coding:

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mandelbrot Explorer</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
  body { font: 13px/1.4 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #e7e7e7; }
  canvas { display: block; width: 100vw; height: 100vh; cursor: grab; }
  canvas.dragging { cursor: grabbing; }
  
  .hud {
    position: fixed; pointer-events: none; 
    text-shadow: 0 1px 2px rgba(0,0,0,.7);
  }
  .hud.top-left { top: 12px; left: 14px; }
  .hud.top-right { top: 12px; right: 14px; pointer-events: auto; }
  .hud.bottom-left { bottom: 12px; left: 14px; font-size: 12px; opacity: .75; }
  
  .pill {
    background: rgba(20,20,30,.45);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    border: 1px solid rgba(255,255,255,.07);
    border-radius: 8px;
    padding: 8px 12px;
  }
  
  .title { font-weight: 600; letter-spacing: .02em; }
  .small { font-size: 11px; opacity: .7; margin-top: 2px; font-feature-settings: "tnum"; }
  
  .row { font-feature-settings: "tnum"; }
  .row span:first-child { opacity: .65; display: inline-block; min-width: 64px; }
  
  .panel {
    pointer-events: auto;
    background: rgba(20,20,30,.55);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255,255,255,.08);
    border-radius: 10px;
    padding: 10px 12px;
    min-width: 220px;
  }
  .panel .ph {
    display: flex; justify-content: space-between; align-items: center;
    font-size: 11px; letter-spacing: .12em; text-transform: uppercase;
    opacity: .65; margin-bottom: 8px;
  }
  .ctl { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 12px; }
  .ctl label { flex: 1; opacity: .8; }
  .ctl .val { font-feature-settings: "tnum"; min-width: 36px; text-align: right; opacity: .9; }
  input[type="range"] { flex: 2; accent-color: #ff8ad8; }
  
  .btn {
    cursor: pointer; user-select: none;
    background: rgba(255,255,255,.06);
    border: 1px solid rgba(255,255,255,.1);
    color: inherit; font: inherit;
    padding: 6px 10px; border-radius: 6px;
    transition: background .15s ease;
  }
  .btn:hover { background: rgba(255,255,255,.12); }
  .btn.active { background: rgba(255,138,216,.18); border-color: rgba(255,138,216,.4); }
  
  .btnrow { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
  
  .legend { pointer-events: none; }
</style>
</head>
<body>
<canvas id="c"></canvas>

<div class="hud top-left">
  <div class="pill">
    <div class="title">Mandelbrot Explorer</div>
    <div class="row small"><span>center</span><span id="cx">-0.5</span></div>
    <div class="row small"><span></span><span id="cy">0.0</span></div>
    <div class="row small"><span>zoom</span><span id="zo">1.0×</span></div>
    <div class="row small"><span>iter</span><span id="it">500</span></div>
  </div>
</div>

<div class="hud top-right">
  <div class="panel">
    <div class="ph"><span>controls</span><span style="opacity:.5">·</span></div>
    <div class="ctl">
      <label for="iter">iterations</label>
      <input type="range" id="iter" min="100" max="3000" step="50" value="700">
      <span class="val" id="iterv">700</span>
    </div>
    <div class="ctl">
      <label for="pal">palette hue</label>
      <input type="range" id="pal" min="0" max="100" step="1" value="20">
      <span class="val" id="palv">20</span>
    </div>
    <div class="ctl">
      <label for="cyc">cycle</label>
      <input type="range" id="cyc" min="0" max="20" step="1" value="6">
      <span class="val" id="cycv">6</span>
    </div>
    <div class="btnrow">
      <button class="btn" id="reset">reset</button>
      <button class="btn" id="anim">shimmer</button>
      <button class="btn" id="invert">invert</button>
    </div>
  </div>
</div>

<div class="hud bottom-left">
  <span class="legend">drag to pan · scroll to zoom · double-click to reset</span>
</div>

<script>
(() => {
  const canvas = document.getElementById('c');
  const gl = canvas.getContext('webgl', { antialias: false, preserveDrawingBuffer: false, premultipliedAlpha: false });
  if (!gl) { document.body.innerHTML = '<div style="color:#fff;padding:20px">WebGL not supported</div>'; return; }
  
  // State
  const state = {
    cx: -0.5, cy: 0.0,
    zoom: 1.0,          // pixels per mandelbrot unit (height-based reference)
    iter: 700,
    palOff: 0.20,
    cycle: 6,
    invert: 0,
    anim: false,
    animT: 0,
  };
  
  // ... mouse/wheel handling, shaders, render loop ...
})();
</script>
</body>
</html>
```

Let me think about zoom semantics again. I want zoom=1 to roughly show the full Mandelbrot set (from about -2.5 to 1 horizontally = 3.5 units wide, -1.25 to 1.25 vertically = 2.5 units tall). So if zoom = window height / 2.5, that fits vertically.

Let me define `zoom` as "pixels per mandelbrot unit". On a 800px tall window, default zoom = 800 / 3 ≈ 267 (showing 3 units tall, which shows everything with some room).

Hmm, let me just define a scale factor relative to "full view". I'll use `scale` = half-height in mandelbrot units. Initial scale = 1.25, showing y range ±1.25. As we zoom in, scale decreases. As we zoom out, scale increases.

Actually let me keep it intuitive and use a `zoom` value where higher = more zoomed in. Let's define `viewHeight = 2.5 / zoom` (showing 2.5/zoom units tall). 

Pixel to complex: 
- y_pixel = 0 (top) → mandelbrot y = cy + (viewHeight / 2) * (something) 

Let me try: 
```glsl
vec2 fragCoord = gl_FragCoord.xy; // pixel coords with y-up
vec2 res = u_resolution;
float view = u_view; // half-height of view in mandelbrot units
float ar = res.x / res.y;
vec2 c = vec2(
    (fragCoord.x - res.x*0.5) / res.y * view * 2.0 + u_center.x,
    (fragCoord.y - res.y*0.5) / res.y * view * 2.0 + u_center.y
);
```

Where `view` is half-height of view in mandelbrot units. Default view = 1.25 to show full set with some margin.

Hmm I'm overcomplicating. Let me use this:

```glsl
// aspect-aware: use height as reference
float scale = 2.0 * u_view / u_resolution.y;
vec2 c = (gl_FragCoord.xy - 0.5 * u_resolution) * scale + u_center;
```

When `u_view = 1.25`, the y range visible is [-1.25, +1.25]. The x range depends on aspect ratio. This is clean.

In JS:
```js
function pxToComplex(px, py) {
  const scale = 2 * view / canvas.height;
  return {
    x: state.cx + (px - canvas.width/2) * scale,
    y: state.cy + (canvas.height - py - canvas.height/2) * scale  // flip y if needed
  };
}
```

Wait, gl_FragCoord.y is typically from bottom. In CSS, y is from top. When user drags, we want to track where their mouse is. Let me think:
- Mouse event: clientX, clientY (with y-down, top-left origin)
- I want: drag down by Δy pixels → view moves up by Δy pixels in mandelbrot space? Actually it depends on the natural mapping.

Let me just be consistent: view mapping has cy at center. When mouse goes down by Δy, the new view center cy_new should be such that the same mandelbrot point that was at mouse y is still at mouse y.

Old mouse position: c_y = cy_old + (mouseY_from_top - H/2) * scale
New mouse position (after drag down by Δy pixels): mouseY_new = mouseY_old + Δy
We want: c_y_old = cy_new + (mouseY_new - H/2) * scale
So: cy_new = c_y_old - (mouseY_new - H/2) * scale
     = cy_old + (mouseY_old - H/2) * scale - (mouseY_old + Δy - H/2) * scale
     = cy_old - Δy * scale

So if y-down in screen coords: pan down (mouse goes down by Δy) → center moves down by Δy*scale. Same for x: drag right → center moves right by dragX*scale.

OK. Let's go with:
- screenY goes top-to-bottom (mouse coords)
- complexY goes bottom-to-top (mandelbrot convention)
- screenY = 0 at top → complexY is largest? Actually let's align them: when user drags down, view goes down too. If cy is "where top of screen is", then... hmm.

Let me restart cleanly. Define:
- state.cy = vertical position of view CENTER in mandelbrot coords
- pixel y in [0, H], top-down
- mandelbrot y for pixel y: my = cy + (H/2 - y) * scale, i.e., y=0 (top) → my = cy + H/2*scale (top of view), y=H (bottom) → my = cy - H/2*scale (bottom of view)

Wait actually let me just say: pixel position → complex coord mapping:
my = state.cy + (H/2 - y) * scale

Then when user drags down by Δy (in screen pixels), their mouse is now at a different mandelbrot point. The mandelbrot point at the OLD mouse position should now be at the NEW mouse position. To achieve this:
- NEW mouse screen y = OLD mouse y + Δy
- mandelbrot y at new mouse = cy_new + (H/2 - (oldMouseY + Δy)) * scale = cy_new + (H/2 - oldMouseY)*scale - Δy*scale
- = (cy_new - Δy*scale) + (H/2 - oldMouseY)*scale
- We want this = mandelbrot y at OLD mouse = cy_old + (H/2 - oldMouseY) * scale
- So cy_new - Δy*scale = cy_old → cy_new = cy_old + Δy*scale

OK so drag down by Δy → cy increases by Δy*scale. Drag right → cx increases. That matches intuition.

Hmm wait, in my shader I have:
```glsl
vec2 c = (gl_FragCoord.xy - 0.5 * u_resolution) * scale + u_center;
```

gl_FragCoord.y goes bottom-up by default in WebGL. So fragCoord.y = 0 is at bottom of canvas. y = H is at top. To map mouse screen y to fragCoord y, I need to flip: fragCoord.y = H - mouseScreenY (approximately; needs adjustment for canvas top offset which is 0 since canvas is full screen).

Actually I don't need mouse-to-shader conversion in JS. I just need to compute the new center based on drag in screen pixel deltas. The shader handles its own coordinates; I just need consistent semantics.

Let me say:
- view mapping in shader: fragCoord.y = bottom = small y; fragCoord.y = top = large y
- c.y at fragCoord.y = H is LARGE (top)
- mouse screen y = 0 at top = maps to fragCoord.y = H
- So fragCoord.y = H - mouseScreenY

In the shader: c.y = (fragCoord.y - H/2) * scale + cy
For mouse at screen y=0 (top): fragCoord.y = H → c.y = H/2 * scale + cy (top of view, large)
For mouse at screen y=H (bottom): fragCoord.y = 0 → c.y = -H/2*scale + cy (bottom of view, small)

So in shader, increasing fragCoord.y → increasing c.y. Good.

For drag: as user drags mouse down by Δy (screen pixels), mouseScreenY increases by Δy, so the matching fragCoord.y decreases by Δy. The mandelbrot point at the new mouse is c.y_new = -Δy*scale + (H - mouseScreenY_old) * scale + cy. Wait this is getting confusing. Let me just compute using drag deltas in screen space directly.

Conceptually: if user drags the screen content by (Δx, Δy) pixels (where positive Δy = down), then the view center should shift by (Δx, Δy) in screen-pixel-equivalent coords:
- center moves by (Δx, Δy) screen pixels in the same direction
- in complex coords: cx_new = cx_old + Δx * scale, cy_new = cy_old - Δy * scale? Or +?

Let me redo. In shader space (mandelbrot y in shader coords):
c.y = (fragCoord.y - H/2) * scale + cy
For drag down by Δy pixels in screen coords, the user sees the content move down. To make this happen, the view needs to "scroll down", i.e., the mandelbrot y at the same screen position should DECREASE (because what was at the top is now lower). 

Argh, let me just experiment once I have the code. I'll use the convention:
- Drag right by Δx_screen → new cx = cx_old + Δx_screen * scale (view scrolls right, content appears to move left, which is the natural feeling? Hmm)

Hmm, actually when you drag content to the right, you're grabbing it and pulling it right. The view follows the content, so the view center shifts right. So the mandelbrot x at screen center moves to higher values. cx_new = cx_old + Δx_screen * scale. OK that matches.

For y: drag down by Δy_screen (in screen pixels). View shifts down. The mandelbrot y at the bottom of screen is now a bigger number? No wait, the view shifted down means the same mandelbrot content appears lower on screen. The mandelbrot point that's now at the screen center is what was below the center. Hmm, that's confusing.

Let me just think in terms of what should happen to cy when user drags:
- User expects: drag down → see what's below. So view "moves down" — but in what sense? In screen sense? No, in mandelbrot sense: see things at lower y.
- So cy_new < cy_old.
- Specifically: the point that was previously off-screen below should come into view.
- Amount: cy_new = cy_old - Δy_screen * scale (mandelbrot units)
- Or +? Let's check: user was looking at center cy_old. Drags down by Δy_screen. Now wants to see things that were Δy_screen below. So new center = cy_old + (something) where mandelbrot y at screen center is now lower than before. cy_new = cy_old - Δy_screen * scale. 

Hmm wait. cy_old is the previous center's y. After drag, the new center should show the things that were previously below. Things below have lower mandelbrot y. So cy_new should be LOWER. cy_new = cy_old - Δy_screen * scale. OK.

For the shader: c.y = (fragCoord.y - H/2) * scale + cy. As cy decreases, c.y decreases for any given fragCoord.y. So drag content moves up on screen (since lower c.y at the top means content shifted up). Wait that's wrong.

Argh. I think the issue is the y-flip between screen and fragCoord. Let me redo carefully:

Shader: c.y at pixel (fragCoord.x, fragCoord.y) = (fragCoord.y - H/2) * scale + cy
- Top of screen: fragCoord.y = H → c.y = H/2*scale + cy (largest c.y)
- Bottom of screen: fragCoord.y = 0 → c.y = -H/2*scale + cy (smallest c.y)
- So "top of screen" maps to LARGEST c.y. That means screen y-down maps to c.y up (inverted).

Mouse event: clientY down = positive. Mouse at clientY=0 (top) → fragCoord.y = H → largest c.y (top of view in shader).

If user wants to scroll DOWN to see things at lower c.y, they drag with mouse (i.e., move mouse down). The intuitive behavior: as user drags down, content on screen moves up. So the view "looks down" → new center has smaller c.y → cy_new < cy_old.

For drag by ΔclientY (positive = down):
cy_new = cy_old - ΔclientY * scale

Let me verify: user starts at (cx, cy_old) looking at cy_old in mandelbrot. The screen shows c.y from (cy_old - H/2*scale) at bottom to (cy_old + H/2*scale) at top.

User drags down by ΔclientY = 50 pixels. Now mouse is 50 pixels lower on screen. The intuition: user is "panning" the view to follow their drag. The view center moves to where their finger is conceptually.

The point on screen at OLD mouse position (top of where finger was, say y_mouseOld) had c.y_mouseOld = (H - y_mouseOld - H/2) * scale + cy_old = (H/2 - y_mouseOld) * scale + cy_old.

After pan, this same c.y_mouseOld should be at NEW mouse position (y_mouseNew = y_mouseOld + ΔclientY). At the new position, c.y = (H/2 - y_mouseNew) * scale + cy_new.

Set them equal: (H/2 - y_mouseOld) * scale + cy_old = (H/2 - y_mouseOld - ΔclientY) * scale + cy_new
=> cy_new = cy_old + ΔclientY * scale

So cy_new > cy_old when ΔclientY > 0 (drag down). That means the center c.y INCREASED after drag-down. View shifted to higher c.y. 

In shader terms: c.y at any pixel now has c.y added by ΔclientY*scale. So the same physical pixel now displays a point that was previously ΔclientY*scale above (in c.y terms).

What does this LOOK like? At pixel (x, y) we now see (c.y_old + ΔclientY*scale). The pixel previously showed c.y_old. To get the same c.y in the new frame at the current pixel, you'd need to look up. Equivalently: the content that was previously at this pixel has shifted up (smaller clientY / larger fragCoord.y). On screen, the content visually moves up.

But user dragged down! They expect content to move down (so they can see what's below). Hmm, actually no — when you drag DOWN with a finger (on a phone map), the content moves DOWN with your finger (you're pushing it). So content on screen moves DOWN. Equivalently, content on screen visually goes up means I dragged it upward.

I'm getting confused. Let me reason differently:
- When you click and drag an image, the image moves WITH your finger. You expect the content under your finger to stay under your finger.
- If your finger moves down by 50 pixels, the content at the old finger position must now appear at the new finger position (which is 50 pixels lower on screen).
- So the same image point, which was previously at clientY_old, is now at clientY_new = clientY_old + 50.
- This means the content shifted DOWN by 50 pixels on screen.
- Equivalent: view center shifted down by 50 pixels in screen coords.
- In mandelbrot coords: view center's c.y shifted by Δy where Δy is determined by screen-to-mandelbrot mapping.
- If screen-y-down corresponds to c.y-up (because of shader inversion), then shifting view center DOWN in screen coords = shifting c.y DOWN (since screen and c.y are inverted, and screen down = c.y down). Wait I had them inverted, so screen-down = c.y-down? Let me re-examine.

In my shader: at fragCoord.y = 0 (bottom), c.y is SMALL. At fragCoord.y = H (top), c.y is LARGE. So fragCoord.y = H/2 + Δy_screen corresponds to c.y = cy + Δy_screen * scale (where Δy_screen in screen coords).

But fragCoord.y in CSS terms: clientY 0 (top) ↔ fragCoord.y H (top in shader, since shader has y-up by default). Hmm, both are "top of screen". So fragCoord.y = H - clientY (since clientY 0 = top in both).

So clientY=0 → fragCoord.y = H → c.y = H/2*scale + cy (top of view)
clientY=H → fragCoord.y = 0 → c.y = -H/2*scale + cy (bottom of view)

Got it. So screen top = c.y top (large c.y). No inversion! 
clientY 0 (screen top) = c.y maximum = cy + H/2*scale.

This is the opposite convention from "math y-up". The y axis goes top-of-screen to bottom-of-screen but the c.y values also go large-to-small. So screen-down = c.y DOWN. 

Now back to drag:
- User drags down (clientY increases) by ΔclientY.
- Image moves down (same screen pixel offset).
- The c.y of any image point on screen shifts by +ΔclientY*scale? No wait. If the image moves down on screen, then the c.y at any given screen position is... what was previously at a higher position on screen.
- At pixel (x, clientY), the new c.y is what was previously at (x, clientY - ΔclientY).
- Old c.y at (x, clientY - ΔclientY) = (fragCoord.y - H/2)*scale + cy_old, where fragCoord.y = H - (clientY - ΔclientY) = H - clientY + ΔclientY.
- = (H - clientY + ΔclientY - H/2)*scale + cy_old
- = (H/2 - clientY + ΔclientY)*scale + cy_old
- New c.y at (x, clientY) = (H/2 - clientY)*scale + cy_new.
- Setting equal: (H/2 - clientY)*scale + cy_new = (H/2 - clientY + ΔclientY)*scale + cy_old
- cy_new = cy_old + ΔclientY*scale

So with drag-down, cy_new > cy_old. View center c.y moves UP (in mandelbrot coords). Equivalently, the content moves DOWN on screen. ✓ Matches expectation.

Then for zoom:
- User scrolls (wheel) by deltaY (positive = scroll down). We use deltaY to compute zoom factor.
- Let's set: zoom changes by factor 1.1^(-deltaY/100) or similar.
- The mandelbrot point under the mouse should NOT move.
- Mouse at screen position (clientX, clientY). 
- Before zoom: c = ((clientX - W/2)*scale + cx_old, (H/2 - clientY)*scale + cy_old)
  Wait I need to be careful with x too.
  
Actually let me redo: in CSS/event coords, mouse is at (clientX, clientY) with clientY down. fragCoord for clientY is (clientX, H - clientY). Plug into shader:
c.x = (clientX - W/2) * scale + cx
c.y = (H - clientY - H/2) * scale + cy = (H/2 - clientY)*scale + cy
So:
c.x = (clientX - W/2) * scale + cx
c.y = (H/2 - clientY) * scale + cy

OK. Now for zoom around mouse position. After zoom (scale changes from s_old to s_new with s_new < s_old meaning more zoomed in):
- The point at (clientX, clientY) is unchanged in mandelbrot coords:
- (clientX - W/2) * s_new + cx_new = (clientX - W/2) * s_old + cx_old
- cx_new = cx_old + (clientX - W/2) * (s_old - s_new)
- Similarly: cy_new = cy_old + (H/2 - clientY) * (s_new - s_old)
  (since for y, c.y = cy + (H/2 - clientY) * scale, so c.y unchanged means cy_new + (H/2 - clientY)*s_new = cy_old + (H/2 - clientY)*s_old, hence cy_new = cy_old + (H/2 - clientY) * (s_old - s_new))

So both:
cx_new = cx_old + (mouseX_from_center) * (s_old - s_new)
cy_new = cy_old + (mouseY_from_center) * (s_old - s_new)  // where mouseY_from_center uses H/2 - clientY for the "up-is-positive" convention

In code, easier to track `view` (half-height of view in mandelbrot units) and `scale` derived from it. Let me track `view` and compute scale = 2 * view / H.

Actually I realize I've been swapping terms. Let me unify:
- `viewHeight` = 2 * view: full vertical span of view in mandelbrot units.
- `scale = viewHeight / canvasHeight`: mandelbrot units per pixel (vertically).
- Initially viewHeight ≈ 3 (showing y from -1.5 to +1.5), but actually a slightly less for default Mandelbrot view that shows from -1.25 to 1.25 (height 2.5).

Let me define: at start, view = 1.5 (showing y from -1.5 to 1.5, fits the set), or 1.25 for a tighter fit. Let's go with 1.5.

Actually the default Mandelbrot extends y from about -1 to 1 mostly, with the main cardiod at y ≈ 0 to about |y|=1 (the bulb touches y=1). So view=1.25 fits it well with a bit of margin. But view=1.5 gives more "negative space" which looks prettier. Let me use view=1.25 as starting.

Hmm, the cardioid+period-2 bulb spans roughly y ∈ [-0.27, 0.27] and the rest of the set's extent in y is about [-1.0, 1.0] (broader bulges). Total set extent in y is about [-1.0, 1.0] but doesn't fill that. View=1.25 vertical fits it perfectly with a tiny bit of margin.

OK enough deliberation, let's go.

Now for zoom math: let's just track `view` (the half-height, in mandelbrot units) and `cx`, `cy`.

```js
function applyZoom(clientX, clientY, factor) {
  const oldView = state.view;
  const newView = oldView / factor; // zoom in
  const w = canvas.clientWidth;  // CSS pixels, since mouse coords are CSS
  const h = canvas.clientHeight;
  // mouse offset from center, in CSS pixels
  const mx = clientX - w / 2;
  const my = (h / 2 - clientY);  // up-positive, matching shader
  // pre-zoom screen-coord mandelbrot:
  // c.x = state.cx + mx * scale_old, where scale_old = 2 * oldView / h
  // For this to stay at same clientX,clientY under new zoom:
  // c.x = state.cx_new + mx * scale_new
  // state.cx_new = state.cx + mx * (scale_old - scale_new)
  state.cx += mx * (2 * oldView / h - 2 * newView / h);
  state.cy += my * (2 * oldView / h - 2 * newView / h);
  state.view = newView;
}
```

Wait, my is `(h/2 - clientY)` because shader has top-of-screen = high c.y. And mx is `clientX - w/2`.

But wait, when computing c.x: mx should map to c.x. In shader: c.x = (fragCoord.x - w/2) * scale + cx. fragCoord.x = clientX (since canvas is fullscreen with no offset). So mx = clientX - w/2 directly. Good.

For c.y: shader's fragCoord.y = h - clientY. So c.y = (h - clientY - h/2)*scale + cy = (h/2 - clientY)*scale + cy. So my = h/2 - clientY. Good.

For zoom preservation: c.x_new at mouse position = c.x_old. 
c.x_old = state.cx + mx * scale_old = state.cx + (clientX - w/2) * (2*oldView/h)
c.x_new = state.cx_new + mx * scale_new = state.cx_new + (clientX - w/2) * (2*newView/h)
=> state.cx_new = state.cx + (clientX - w/2) * (2/h) * (oldView - newView)
=> state.cx_new += mx * 2 * (oldView - newView) / h

Yes that matches my formula. Good.

For drag (pan): if user drags from (clientX0, clientY0) to (clientX1, clientY1), the content on screen stays under the cursor:
content_screen_at_x0 = state.cx + (clientX0 - w/2) * scale
For this content to now be at clientX1:
state.cx_new + (clientX1 - w/2) * scale = state.cx_old + (clientX0 - w/2) * scale
=> state.cx_new = state.cx_old + (clientX0 - clientX1) * scale
=> state.cx_new -= (clientX1 - clientX0) * scale
=> dx_drag = clientX1 - clientX0; state.cx -= dx_drag * scale

Similarly state.cy -= dy_drag * scale where dy_drag = clientY1 - clientY0.

But scale here uses h, not w. Let me check. The `2/h` factor only matters for y. For x, x-dimension uses `w` not `h` because we use aspect-aware mapping. Hmm wait.

Actually, I had:
```glsl
vec2 c = (gl_FragCoord.xy - 0.5 * u_resolution) * scale + u_center;
```
where `scale = 2 * view / resolution.y`. So the same scale is applied to x and y, but since resolution.x ≠ resolution.y, the effective "zoom per pixel" differs between x and y. Specifically, scale_x_per_pixel = 2 * view / h, scale_y_per_pixel = 2 * view / h (same). So 1 pixel horizontally = same mandelbrot distance as 1 pixel vertically. Aspect ratio is handled by the canvas: if w > h, we see more in x direction than y.

Right, so for drag:
state.cx -= dragX_screen * scale = dragX_screen * 2*view/h
state.cy -= dragY_screen * scale

Same scale for both. Both w/h division is implicit in the canvas aspect.

OK so pan formula:
```js
state.cx -= (clientX1 - clientX0) * 2 * state.view / canvas.clientHeight;
state.cy -= (clientY1 - clientY0) * 2 * state.view / canvas.clientHeight;
```

Great. Let me code now.

For touch:
- Single touch drag = pan
- Pinch = zoom (compute distance between two fingers before and after, adjust view ratio)

I'll keep touch simple — just support single touch drag for now. Pinch is bonus.

Actually, let me support pinch too since it's common on tablets.

Now WebGL setup. Vertex shader is trivial — just draw a fullscreen quad:

```glsl
attribute vec2 a_pos;
void main() {
  gl_Position = vec4(a_pos, 0.0, 1.0);
}
```

Fragment shader:

```glsl
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_center;
uniform float u_view;
uniform float u_palOff;
uniform float u_cycle;
uniform float u_invert;
uniform int u_iter;

const int MAX_LOOP = 4000;

vec3 palette(float t) {
  // Inigo Quilez style palette
  vec3 a = vec3(0.5, 0.5, 0.5);
  vec3 b = vec3(0.5, 0.5, 0.5);
  vec3 c = vec3(1.0, 1.0, 1.0);
  vec3 d = vec3(0.0, 0.33, 0.67);
  return a + b * cos(6.28318530718 * (c * t + d));
}

void main() {
  vec2 fragCoord = gl_FragCoord.xy;
  float scale = 2.0 * u_view / u_resolution.y;
  vec2 c = (fragCoord - 0.5 * u_resolution) * scale + u_center;
  
  vec2 z = vec2(0.0);
  float n = 0.0;
  
  for (int i = 0; i < MAX_LOOP; i++) {
    if (i >= u_iter) break;
    z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
    if (dot(z, z) > 256.0) break;
    n += 1.0;
  }
  
  if (n >= float(u_iter)) {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }
  
  // Smooth coloring
  float smooth = n + 1.0 - log(log(length(z))) / log(2.0);
  float t = smooth * u_cycle / float(u_iter) + u_palOff;
  
  vec3 col = palette(t);
  col = mix(col, 1.0 - col, u_invert);
  gl_FragColor = vec4(col, 1.0);
}
```

Wait, there's a precision concern with `dot(z, z) > 256.0`. For Mandelbrot, we use 2^2 = 4 as the escape radius squared (since once |z| > 2, definitely escapes; using larger like 256 saves some iterations). Actually 256 is too large — many points with |z| between 2 and 16 will end up diverging, but the smooth coloring formula's log(log(|z|)/log(2)) assumes we caught them just past escape. For accuracy, smaller is better. Let me use 16 or so. Actually 4 is the standard choice but it adds iterations; 16 is fine with the smooth coloring correction as long as |z| is small enough.

Hmm, with float32, `length(z)` for z up to 16 = 16, so log(16)/log(2) = 4. log(4)/log(2) = 2. Smooth = n + 1 - 2 = n - 1. Roughly OK.

Actually the cleaner version: escape when |z| > 2 (squared > 4). Then log(log(2))/log(2) = 0, so smooth = n + 1. That's nicer. Let me use 4.

But that means more iterations. With high zoom and high max-iter, could be slow. Most GPUs handle 1000-2000 iterations per pixel fine, though. Let me use `dot(z,z) > 4.0` (or use `length(z) > 2.0` but that requires sqrt which is slower).

Actually, dot is fast. Use `dot(z,z) > 4.0` (escape if |z|² > 4, i.e., |z| > 2).

Hmm, with maxIter up to 3000, that's 3000 iterations per pixel. With 1920x1080 at dpr 2 = 3840x2160 ≈ 8M pixels * 3000 iters = 24G iterations. That's a lot but GPUs are fast. Could be slow though. Let me cap and also consider that user's max iter should default moderate like 700.

Actually let me reconsider. For typical interactive use, 256-1024 iters is plenty for moderate zoom. For deep zoom (around minibrot), might need 1000-3000. Default 700. Let me have a slider 100-2000.

Hmm or maybe split: slider goes 100-2000 with step 50, default 600. OK.

WebGL uint vs float issues: u_iter as int uniform. Most browsers handle int uniforms. Let me declare as int and use cast when comparing with float.

For `i >= u_iter` where i is int and u_iter is int, should work directly.

For `n >= float(u_iter)`, explicit cast needed.

Alright, let me code it up:

```js
const vsSource = `
attribute vec2 a_pos;
void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
`;

const fsSource = `
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_center;
uniform float u_view;
uniform float u_palOff;
uniform float u_cycle;
uniform float u_invert;
uniform int u_iter;

const int MAX_LOOP = 2000;

vec3 palette(float t) {
  vec3 a = vec3(0.5);
  vec3 b = vec3(0.5);
  vec3 c = vec3(1.0);
  vec3 d = vec3(0.0, 0.33, 0.67);
  return a + b * cos(6.28318530718 * (c * t + d));
}

void main() {
  vec2 fragCoord = gl_FragCoord.xy;
  float scale = 2.0 * u_view / u_resolution.y;
  vec2 c = (fragCoord - 0.5 * u_resolution) * scale + u_center;
  
  vec2 z = vec2(0.0);
  float n = 0.0;
  float maxN = float(u_iter);
  bool escaped = false;
  
  for (int i = 0; i < MAX_LOOP; i++) {
    if (i >= u_iter) break;
    z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
    if (dot(z, z) > 4.0) { escaped = true; break; }
    n += 1.0;
  }
  
  if (!escaped) {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }
  
  float smooth = n + 1.0 - log(log(length(z))) / log(2.0);
  float t = smooth * u_cycle / maxN + u_palOff;
  
  vec3 col = palette(t);
  col = mix(col, 1.0 - col, u_invert);
  gl_FragColor = vec4(col, 1.0);
}
`;
```

Wait `i >= u_iter` where u_iter is from JS as `gl.uniform1i(loc, value)`. Let me set MAX_LOOP slightly larger than max iter slider max. If slider goes 100-2000, MAX_LOOP should be 2000. But shader allows MAX_LOOP=2000 fixed, so slider shouldn't exceed 2000. Or I make MAX_LOOP=3000 and slider 100-2500.

Let me use MAX_LOOP=2000 with slider max=1500 to be safe (or just bump MAX_LOOP). Actually let me make MAX_LOOP=2500 and slider max=2000 to be safe and performant.

Actually, for performance on GPUs, setting max to 3000 is fine. Let me do that.

Hmm, let me think about: if a user wants very deep zoom, they might crank iter to 5000+. WebGL 1 with GLSL 1.00 has the constraint of constant loop bounds. To support varying max iters, one workaround is "early termination" via break when i >= u_iter. But the loop is still unrolled to MAX_LOOP iterations on most compilers. So MAX_LOOP effectively sets the per-pixel worst case.

Let me set MAX_LOOP=2000 with slider max=1500. That keeps it fast. For users wanting more, they can edit the shader source. (Or I can expose MAX_LOOP via slider but that requires recompile.)

Simpler: slider 100-1500 step 50, default 600. Or 100-2000 step 50, default 600, MAX_LOOP=2500.

Let me go with slider 100-2000 step 50, default 600, MAX_LOOP=2500 in shader.

Actually I realize: when MAX_LOOP is the unrolled count and we early-exit via break, the GPU still executes all iterations until break. The `if (i >= u_iter) break;` doesn't help GPU because the compiler likely just unrolls the entire MAX_LOOP. 

In practice, GLSL ES 1.00 (WebGL 1) generally requires constant loop bounds. The compiler may unroll the entire loop. Modern implementations handle dynamic exits OK but performance varies.

For a snappy experience: MAX_LOOP should be set just high enough that we don't waste cycles. 2000 with max iter 1500 in slider is reasonable.

I'll go with:
- MAX_LOOP = 2000 in shader
- Slider max = 1500 (step 50), default 700

I think for a quality demo that's plenty.

OK enough planning. Let me write the file.

Let me also add a "double click to reset" feature and make the look polished.

One more thing: for the very tight deep zooms, you get pixelation due to float32 precision. Beyond that, it's hard to improve without perturbation. I'll just let it happen and the user can reset.

Layout for HUD: top-left info pill, top-right control panel, bottom-left legend. Subtle, doesn't obstruct. Looks like a Google Maps-like minimal UI.

Let me write it all out.

Actually, one more touch: a small "loading" indicator? Nope, with WebGL it's instant first paint. Skip.

Initial values:
- center: (-0.5, 0.0), view: 1.5. That fits the whole set with some margin.

Or I could start at a nicely placed view that shows the cardioid and bulb prominently. (-0.5, 0) shows it well. Or even better: (-0.75, 0) to emphasize the bulb on the right. Let me start at (-0.5, 0).

When the user clicks reset: go back to (-0.5, 0), view=1.5.

Let me also add a subtle gradient framing — too much? No, keep it minimal.

Alright, writing the code now.

One more consideration: gl.uniform setting locations. I need to look up attribute and uniform locations after compiling shaders.

Pseudocode:
```js
const program = createProgram(vsSource, fsSource);
gl.useProgram(program);

const aPos = gl.getAttribLocation(program, 'a_pos');
const uResolution = gl.getUniformLocation(program, 'u_resolution');
const uCenter = gl.getUniformLocation(program, 'u_center');
const uView = gl.getUniformLocation(program, 'u_view');
const uPalOff = gl.getUniformLocation(program, 'u_palOff');
const uCycle = gl.getUniformLocation(program, 'u_cycle');
const uInvert = gl.getUniformLocation(program, 'u_invert');
const uIter = gl.getUniformLocation(program, 'u_iter');

// Create buffer with fullscreen quad
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
```

And in render:
```js
function render() {
  gl.viewport(0, 0, canvas.width, canvas.height);
  gl.uniform2f(uResolution, canvas.width, canvas.height);
  gl.uniform2f(uCenter, state.cx, state.cy);
  gl.uniform1f(uView, state.view);
  gl.uniform1f(uPalOff, state.palOff);
  gl.uniform1f(uCycle, state.cycle);
  gl.uniform1f(uInvert, state.invert ? 1.0 : 0.0);
  gl.uniform1i(uIter, state.iter);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
```

For canvas resize:
```js
function resize() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const w = Math.floor(window.innerWidth * dpr);
  const h = Math.floor(window.innerHeight * dpr);
  if (canvas.width !== w || canvas.height !== h) {
    canvas.width = w;
    canvas.height = h;
    canvas.style.width = window.innerWidth + 'px';
    canvas.style.height = window.innerHeight + 'px';
    needsRender = true;
  }
}
```

Debounce or just call on every resize event (they're not super frequent).

For mouse handling:
```js
let dragging = false;
let lastX = 0, lastY = 0;
let pinchStart = null;

canvas.addEventListener('mousedown', e => {
  dragging = true;
  lastX = e.clientX;
  lastY = e.clientY;
  canvas.classList.add('dragging');
});

window.addEventListener('mousemove', e => {
  if (!dragging) return;
  const dx = e.clientX - lastX;
  const dy = e.clientY - lastY;
  lastX = e.clientX;
  lastY = e.clientY;
  const scale = 2 * state.view / canvas.clientHeight;
  state.cx -= dx * scale;
  state.cy -= dy * scale;
  needsRender = true;
  updateHUD();
});

window.addEventListener('mouseup', () => {
  dragging = false;
  canvas.classList.remove('dragging');
});

canvas.addEventListener('wheel', e => {
  e.preventDefault();
  const factor = Math.exp(-e.deltaY * 0.0015);
  const oldView = state.view;
  const newView = clamp(oldView / factor, 1e-15, 4);
  // adjust center so mouse anchor stays put
  const mx = e.clientX - canvas.clientWidth / 2;
  const my = canvas.clientHeight / 2 - e.clientY;
  state.cx += mx * (2 * oldView / canvas.clientHeight - 2 * newView / canvas.clientHeight);
  state.cy += my * (2 * oldView / canvas.clientHeight - 2 * newView / canvas.clientHeight);
  state.view = newView;
  needsRender = true;
  updateHUD();
}, { passive: false });

canvas.addEventListener('dblclick', e => {
  reset();
});

reset button -> reset();
```

Update HUD: update DOM with current state values.

For touch:
```js
canvas.addEventListener('touchstart', e => {
  e.preventDefault();
  if (e.touches.length === 1) {
    dragging = true;
    lastX = e.touches[0].clientX;
    lastY = e.touches[0].clientY;
  } else if (e.touches.length === 2) {
    dragging = false;
    const t1 = e.touches[0], t2 = e.touches[1];
    pinchStart = {
      dist: Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY),
      cx: (t1.clientX + t2.clientX) / 2,
      cy: (t1.clientY + t2.clientY) / 2,
      view: state.view,
    };
  }
}, { passive: false });

canvas.addEventListener('touchmove', e => {
  e.preventDefault();
  if (e.touches.length === 1 && dragging) {
    const t = e.touches[0];
    const dx = t.clientX - lastX;
    const dy = t.clientY - lastY;
    lastX = t.clientX;
    lastY = t.clientY;
    const scale = 2 * state.view / canvas.clientHeight;
    state.cx -= dx * scale;
    state.cy -= dy * scale;
    needsRender = true;
    updateHUD();
  } else if (e.touches.length === 2 && pinchStart) {
    const t1 = e.touches[0], t2 = e.touches[1];
    const dist = Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
    const factor = pinchStart.dist / dist;  // pinch out (dist increase) => zoom in
    const oldView = state.view;
    const newView = clamp(oldView / factor, 1e-15, 4);
    // anchor on the pinch midpoint:
    const mxp = pinchStart.cx - canvas.clientWidth / 2;
    const myp = canvas.clientHeight / 2 - pinchStart.cy;
    state.cx += mxp * (2 * oldView / canvas.clientHeight - 2 * newView / canvas.clientHeight);
    state.cy += myp * (2 * oldView / canvas.clientHeight - 2 * newView / canvas.clientHeight);
    state.view = newView;
    needsRender = true;
    updateHUD();
  }
}, { passive: false });

canvas.addEventListener('touchend', e => {
  if (e.touches.length === 0) {
    dragging = false;
    pinchStart = null;
  } else if (e.touches.length === 1) {
    pinchStart = null;
    dragging = true;
    lastX = e.touches[0].clientX;
    lastY = e.touches[0].clientY;
  }
}, { passive: false });
```

OK there's a lot. Let me write the full thing now.

Now, let me think about whether to use WebGL2 for less restrictive shaders or stay with WebGL1 for broader compat. WebGL1 is fine for our needs.

One last design touch: at extreme zoom, the floating-point precision will be insufficient. To go beyond ~10^5 zoom we'd need perturbation theory. For the demo, I'll leave that and just trust users won't push too far. Or, I can use a "double" trick — emulate double precision with two floats. That's possible but complex.

Actually let me skip double precision for now. With highp float32, zoom is fine up to maybe 10^4 or 10^5 depending on location. (At 10^6 zoom, precision is about 10^6 / 2^23 ≈ 10^-1 mandelbrot units, which equals one pixel of the view, so it gets visibly granular.)

When user zooms beyond, the view becomes pixelated but still functional. Not ideal but acceptable.

If I wanted to provide proper deep zoom, I'd implement a perturbation-based renderer using arbitrary precision for the reference orbit. That's overkill for a quick demo.

Let me finalize the code. Oh, and let me set canvas cursor to `crosshair` to indicate clickable. Or `grab` to indicate draggable. I'll use grab/grabbing.

For the screenshot/aesthetic: I want the default view to look striking. I'll start at a nice location. The whole set with -2 < x < 1 and about -1 < y < 1. Showing all of that: view=1.2 vertical fits it.

Actually let me start with view=1.4, center=(-0.5, 0). The view shows -1.8 < x < 0.8 and -1.4 < y < 1.4. Reasonable frame.

Hmm wait, the set extends to x = -2 on left and x = +0.25 on right (the bulb reaches +0.25). My default would cut off the right bulb. Let me use view=1.3 so x ranges -2.6 < x < 1.0 — plenty of room.

Or: view = 1.2, center_x = -0.7, then x ranges: center_x - (ar * view) to center_x + (ar * view). At 16:9 aspect ratio, ar = 16/9 ≈ 1.78. So x range = -0.7 - 2.13 to -0.7 + 2.13 = -2.83 to 1.43. Set fits easily, with some nice negative space to the left showing more detail of the bulb.

Actually wait, the cardioid bulb is centered around -0.75 + 0i, and extends from about x=-1.25 to x=-0.25. So centering around (-0.6, 0) emphasizes it.

You know what, let me just use center=(-0.5, 0), view=1.3. That works fine and is the conventional default.

Or even better, slightly offset: center=(-0.5, 0), view=1.25, that's the canonical view. I'll use that.

Let me write the final HTML.

Wait, one more thing: maxIter default. With view=1.25, the set fits in view and most exterior pixels escape quickly. 200-400 iters is enough at this zoom. But I'll start higher (700) to look richer. As user zooms in, they may need to crank it up.

OK final design decisions:
- Initial: center (-0.5, 0), view 1.25, iter 700
- Slider ranges: iter 100-1500 (step 50), palOff 0-100 (step 1, scaled 0-1), cycle 0-20 (step 1)
- Toggles: shimmer (animates palOff slowly), invert (flips colors)
- Reset button: back to initial

Let me also add the "cycle" control which lets the user adjust how many color cycles per escape sequence. cycle=6 means 6 full color cycles from inside to far outside. cycle=1 means 1 cycle. Higher = more "stripes".

Alright. Time to write.

Code:
```js
(() => {
  const $ = id => document.getElementById(id);
  const canvas = $('c');
  const gl = canvas.getContext('webgl', { antialias: false, preserveDrawingBuffer: false });
  if (!gl) {
    document.body.innerHTML = '<div style="padding:20px;color:#fff;font:14px sans-serif">WebGL is required for this demo.</div>';
    return;
  }
  
  function compile(type, src) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      console.error(gl.getShaderInfoLog(s));
      throw new Error('shader compile failed');
    }
    return s;
  }
  
  function linkProgram(vs, fs) {
    const p = gl.createProgram();
    gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
    gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
    gl.linkProgram(p);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(p));
      throw new Error('link failed');
    }
    return p;
  }
  
  const vsSrc = `
    attribute vec2 a_pos;
    void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }
  `;
  
  const fsSrc = `
    precision highp float;
    uniform vec2 u_resolution;
    uniform vec2 u_center;
    uniform float u_view;
    uniform float u_palOff;
    uniform float u_cycle;
    uniform float u_invert;
    uniform int u_iter;
    
    const int MAX_LOOP = 2000;
    
    vec3 palette(float t) {
      vec3 a = vec3(0.55, 0.50, 0.55);
      vec3 b = vec3(0.50, 0.50, 0.50);
      vec3 c = vec3(1.00, 0.95, 0.85);
      vec3 d = vec3(0.00, 0.20, 0.45);
      return a + b * cos(6.28318530718 * (c * t + d));
    }
    
    void main() {
      vec2 fragCoord = gl_FragCoord.xy;
      float scale = 2.0 * u_view / u_resolution.y;
      vec2 c = (fragCoord - 0.5 * u_resolution) * scale + u_center;
      
      vec2 z = vec2(0.0);
      float n = 0.0;
      bool escaped = false;
      
      for (int i = 0; i < MAX_LOOP; i++) {
        if (i >= u_iter) break;
        z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
        if (dot(z, z) > 4.0) { escaped = true; break; }
        n += 1.0;
      }
      
      if (!escaped) {
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
        return;
      }
      
      // Smooth coloring using log(log(|z|))/log(2)
      float logzn = log(length(z)) / 0.69314718056;  // log(2) = 0.693...
      float smooth = n + 1.0 - log(logzn) / log(2.0);
      float t = smooth * u_cycle / float(u_iter) + u_palOff;
      
      vec3 col = palette(t);
      col = mix(col, 1.0 - col, u_invert);
      gl_FragColor = vec4(col, 1.0);
    }
  `;
  
  let program;
  try {
    program = linkProgram(vsSrc, fsSrc);
  } catch (e) {
    document.body.innerHTML = '<div style="padding:20px;color:#fff;font:14px sans-serif">Shader compilation failed (your GPU may not support this). Try a different browser.</div>';
    return;
  }
  
  gl.useProgram(program);
  
  const loc = {
    aPos: gl.getAttribLocation(program, 'a_pos'),
    uResolution: gl.getUniformLocation(program, 'u_resolution'),
    uCenter: gl.getUniformLocation(program, 'u_center'),
    uView: gl.getUniformLocation(program, 'u_view'),
    uPalOff: gl.getUniformLocation(program, 'u_palOff'),
    uCycle: gl.getUniformLocation(program, 'u_cycle'),
    uInvert: gl.getUniformLocation(program, 'u_invert'),
    uIter: gl.getUniformLocation(program, 'u_iter'),
  };
  
  // Setup fullscreen quad
  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(loc.aPos);
  gl.vertexAttribPointer(loc.aPos, 2, gl.FLOAT, false, 0, 0);
  
  // State
  const state = {
    cx: -0.5, cy: 0,
    view: 1.25,
    iter: 700,
    palOff: 0.20,
    cycle: 6,
    invert: 0,
  };
  const initial = { ...state };
  
  let needsRender = true;
  
  function render() {
    if (!needsRender) return;
    needsRender = false;
    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.uniform2f(loc.uResolution, canvas.width, canvas.height);
    gl.uniform2f(loc.uCenter, state.cx, state.cy);
    gl.uniform1f(loc.uView, state.view);
    gl.uniform1f(loc.uPalOff, state.palOff);
    gl.uniform1f(loc.uCycle, state.cycle);
    gl.uniform1f(loc.uInvert, state.invert);
    gl.uniform1i(loc.uIter, state.iter);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    updateHUD();
  }
  
  function updateHUD() {
    $('cx').textContent = state.cx.toFixed(10);
    $('cy').textContent = state.cy.toFixed(10);
    $('zo').textContent = formatZoom(state.view);
    $('it').textContent = state.iter;
  }
  
  function formatZoom(v) {
    // Express as a multiple of the base view
    return (initial.view / v).toFixed(2) + '×';
  }
  
  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.floor(window.innerWidth * dpr);
    const h = Math.floor(window.innerHeight * dpr);
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
      canvas.style.width = window.innerWidth + 'px';
      canvas.style.height = window.innerHeight + 'px';
      needsRender = true;
    }
  }
  
  // ... mouse, wheel, touch handlers ...
  
  // Animation loop for shimmer & render on demand
  let shimmer = false;
  let lastTime = 0;
  function loop(t) {
    if (shimmer) {
      state.palOff = (state.palOff + 0.0008) % 1;
      needsRender = true;
      $('pal').value = String(Math.round(state.palOff * 100));
      $('palv').textContent = String(Math.round(state.palOff * 100));
    }
    render();
    requestAnimationFrame(loop);
  }
  
  // ... init ...
})();
```

Hmm, "shimmer" updates the slider value every frame which might fight with the slider drag. Let me only update the HUD display, not the underlying state. Wait, I want to update state.palOff AND display it. Let me decouple. Or just only animate if not currently being dragged.

Actually, I'll disable shimmer-controls-slider sync issue by simply: when shimmer is on, increment state.palOff each frame, but only update the displayed number (no need to update the slider position continuously — that's distracting anyway). I'll set the slider position once at toggle-on.

Simpler:
```js
function loop(t) {
  if (shimmer) {
    state.palOff = (state.palOff + 0.0008) % 1;
    needsRender = true;
    $('palv').textContent = String(Math.round(state.palOff * 100));
  }
  render();
  requestAnimationFrame(loop);
}
```

Don't update slider. Fine, just the value display.

Wait, but when user moves the slider, they expect shimmer to stop, or at least the change to take effect. Let me think:
- User toggles shimmer ON: state.palOff starts drifting.
- User moves palOff slider: their value is taken as base offset for next reset... hmm.

Actually, simpler rule: when user moves the slider, set state.palOff directly AND turn shimmer OFF. That way the slider "controls" and shimmer is a separate toggle that turns off when manually overridden.

Or: keep shimmer on but make user slider changes additive / override for one frame? Too complex. 

Let me just say: when user drags the palOff slider, shimmer auto-disables. That's the intuitive behavior.

Similarly for cycle, it doesn't shimmer so no issue.

Invert: doesn't shimmer, single toggle.

Let me code it. Actually, I'll just have shimmer turn off when user starts manipulating the pal slider:

```js
$('pal').addEventListener('input', e => {
  state.palOff = parseInt(e.target.value, 10) / 100;
  $('palv').textContent = e.target.value;
  shimmer = false;
  $('anim').classList.remove('active');
  needsRender = true;
});
```

Etc. for other sliders.

OK enough. Let me write it all out now.

Wait, also: in my loop I'm calling render() every frame, even when nothing changes. That's wasteful. But it ensures needsRender is reset. Actually, I check if needsRender is true at the start of render(), and reset it. So no actual GL work happens if no change. Fine. requestAnimationFrame always runs but render is cheap when nothing to do.

But if I want to use rAF optimally, just don't run the loop on demand. Use direct render-on-demand:

```js
function requestRender() {
  if (!needsRender) {
    needsRender = true;
    requestAnimationFrame(render);
  }
}
```

For interactive state changes. For shimmer, render every frame via rAF. OK two patterns.

Let me simplify with a single pattern: requestAnimationFrame always runs. Renders only when needed.

```js
let needsRender = true;
function tick(t) {
  if (shimmer) {
    state.palOff = (state.palOff + 0.0008) % 1;
    needsRender = true;
    $('palv').textContent = String(Math.round(state.palOff * 100));
  }
  if (needsRender) {
    needsRender = false;
    gl.viewport(0, 0, canvas.width, canvas.height);
    // ... uniforms ...
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    updateHUD();
  }
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
```

That works.

Alright, writing the complete file now.

For the rendering precision and performance: I should also debounce rapid updates. e.g., during wheel zoom, lots of events fire. But rendering 60 times per second is fine for modern GPUs. No debouncing needed.

Memory considerations: We're using 1 buffer and 1 program. Tiny. Fine.

One more thing: the canvas when blinking during initial load might show black. That's expected and fine.

OK let me write the whole damn thing.

Oh, also, for accessibility: keyboard support? Tab to controls, arrow keys to pan, +/- to zoom? That'd be nice but optional. Let me add arrow keys for pan and +/- for zoom.

For the slider controls (iter, palOff, cycle), they're already accessible via Tab + arrow keys (since they're inputs).

Adding keyboard shortcuts:
- arrow keys: pan
- +/=: zoom in
- -: zoom out
- r: reset
- i: toggle invert
- space: toggle shimmer

```js
window.addEventListener('keydown', e => {
  const panDist = state.view * 0.15;
  switch (e.key) {
    case 'ArrowLeft': state.cx -= panDist; needsRender = true; e.preventDefault(); break;
    case 'ArrowRight': state.cx += panDist; needsRender = true; e.preventDefault(); break;
    case 'ArrowUp': state.cy += panDist; needsRender = true; e.preventDefault(); break;
    case 'ArrowDown': state.cy -= panDist; needsRender = true; e.preventDefault(); break;
    case '+': case '=':
      zoomToCenter(0.8);
      e.preventDefault();
      break;
    case '-': case '_':
      zoomToCenter(1.25);
      e.preventDefault();
      break;
    case 'r': case 'R':
      reset();
      e.preventDefault();
      break;
    case 'i': case 'I':
      toggleInvert();
      e.preventDefault();
      break;
    case ' ':
      toggleShimmer();
      e.preventDefault();
      break;
  }
});
```

Let me also add: hold-shift modifies arrow-pan to a smaller amount? Eh, just use the panDist calc.

Wheel zoom in zoomToCenter: 
```js
function zoomToCenter(factor) {
  const oldView = state.view;
  const newView = clamp(oldView / factor, 1e-15, 4);
  state.view = newView;
  // No center adjustment — zoom about current view center (off-mouse zoom)
  needsRender = true;
}
```

Alright. Final code time. Let me draft the full thing:

OK I'm going to commit to a specific design and just write it out completely. No more back-and-forth. Let me start writing.

Actually, double checking the shader: I noticed I declared `u_iter` as `uniform int u_iter;` and use `i >= u_iter` where `i` is the loop variable (int). That should work in GLSL ES 1.0. ✓

`float n = 0.0;` — counted iterations. ✓

`if (!escaped)` — bool comparison. ✓ (GLSL ES 1.0 supports bool, but `if` without const condition means dynamic branching. GPUs handle this OK for early-out loops.)

`dot(z, z) > 4.0` — escape radius squared = 4, so |z| > 2. ✓

Smooth coloring formula:
```
log(log(|z|^2) / log(2)) = log(log(|z|) / log(2)) - log(2) ... 
```

Standard form:
n_smooth = n + 1 - log2(log2(|z|^2)) / 2

Or:
n_smooth = n + 1 - log(log(|z|)) / log(2)

Or using |z|^2:
n_smooth = n + 1 - log(0.5 * log(|z|^2)) / log(2)
        = n + 1 - (log(log(|z|^2) + log(0.5))) / log(2)
        = n + 1 - (log(0.5) + log(log(|z|^2))) / log(2)
        = n + 1 - (-1 + log(log(|z|^2))) / log(2)
        = n + 1 + 1/log(2) - log(log(|z|^2))/log(2)

There are different forms. Let me use the cleaner:
```
n_smooth = n + 1 - log(0.5 * log(dot(z,z))) / log(2)
```

Actually I realize `log2(x) = log(x) / log(2)`. And `log2(log2(|z|^2)) = log(log(|z|^2))/log(2) / log(2) = log(log(|z|^2))/log(4)`.

Common form: n + 1 - log(log(|z|))/log(2). Let me use that, with |z| computed via sqrt:
```
float mag = length(z);
float smooth = n + 1.0 - log(log(mag)) / log(2.0);
```

This is the canonical "smooth iteration count" formula. Works when |z| > 2 (escape).

For unescaped points (`!escaped`), color = black.

OK final shader:

```glsl
precision highp float;
uniform vec2 u_resolution;
uniform vec2 u_center;
uniform float u_view;
uniform float u_palOff;
uniform float u_cycle;
uniform float u_invert;
uniform int u_iter;

const int MAX_LOOP = 2000;

vec3 palette(float t) {
  vec3 a = vec3(0.55, 0.50, 0.55);
  vec3 b = vec3(0.50, 0.50, 0.50);
  vec3 c = vec3(1.00, 0.95, 0.85);
  vec3 d = vec3(0.00, 0.20, 0.45);
  return a + b * cos(6.28318530718 * (c * t + d));
}

void main() {
  vec2 fragCoord = gl_FragCoord.xy;
  float scale = 2.0 * u_view / u_resolution.y;
  vec2 c = (fragCoord - 0.5 * u_resolution) * scale + u_center;
  
  vec2 z = vec2(0.0);
  float n = 0.0;
  bool escaped = false;
  
  for (int i = 0; i < MAX_LOOP; i++) {
    if (i >= u_iter) break;
    z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
    if (dot(z, z) > 4.0) { escaped = true; break; }
    n += 1.0;
  }
  
  if (!escaped) {
    gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
    return;
  }
  
  // Smooth iteration count for continuous coloring
  float smooth_n = n + 1.0 - log(log(length(z))) / log(2.0);
  float t = smooth_n * u_cycle / float(u_iter) + u_palOff;
  
  vec3 col = palette(t);
  col = mix(col, 1.0 - col, u_invert);
  gl_FragColor = vec4(col, 1.0);
}
```

Looks good. Let me finalize.

For initial render, needs to happen after canvas resized. I'll call resize() before starting rAF.

Actually, I realize the canvas resize should happen before the first render, so that we have correct dimensions. Let me order:
1. Get gl context
2. Setup program, buffers
3. Resize canvas (set canvas.width/height) 
4. Begin rAF loop

Actually `needsRender = true` initially ensures first frame draws.

OK let me write the final HTML now.

One thing — I want a darker more "deep space" look with a faint vignette maybe? Maybe not, let the colors speak for themselves. Background is black (set in CSS).

Actually, an idea: make the panel slightly more transparent or use glass effect. Already doing backdrop-filter blur.

OK final code. Let me write it.

I want to check: when drawing the canvas, is gl.uniform2f(u_resolution, w, h) using the actual pixel dimensions (canvas.width, canvas.height) which include dpr scaling? Yes. And fragCoord is in pixel units (also dpr-scaled). So my scale calc `2 * u_view / u_resolution.y` gives the right "mandelbrot-units per pixel" regardless of dpr. 

But wait, the mouse coords are in CSS pixels (e.clientX), not device pixels. And canvas.clientWidth is CSS pixels. So my drag calc uses CSS pixel deltas. With dpr=2, the CSS pixels are half the device pixels. 

Let me check: drag dx CSS pixels. scale (mandelbrot/pixel) = 2 * view / (CSS pixels height * dpr) ... no wait.

In my shader: 
- fragCoord is in device pixels.
- scale = 2 * view / device_height
- 1 device pixel = scale mandelbrot units.
- 1 CSS pixel = dpr device pixels = dpr * scale mandelbrot units.

So when user drags dx CSS pixels, the mandelbrot shift should be dx * dpr * scale = dx * 2 * view / CSS_height.

So `state.cx -= dx * 2 * state.view / canvas.clientHeight`. (canvas.clientHeight is CSS pixels.) ✓ That's what I had.

For `mx = e.clientX - canvas.clientWidth / 2`: this is in CSS pixels. But in the shader fragCoord space, I need this in device pixels. Hmm.

Actually for zoom around mouse: at mouse position, mandelbrot coord = state.cx + (mouse_in_device_pixels - device_width/2) * scale.

mouse_in_device_pixels = e.clientX * dpr (approximately, accounting for canvas left offset which is 0 here).

So:
mouse_offset_in_fragCoord_x = e.clientX * dpr - (canvas.width / 2) = (e.clientX - canvas.clientWidth / 2) * dpr

In mandelbrot:
c.x_at_mouse = state.cx + (e.clientX - canvas.clientWidth / 2) * dpr * scale

For this to stay fixed under new zoom (newView):
state.cx_new + mx_css * dpr * newScale = state.cx + mx_css * dpr * oldScale
=> state.cx_new = state.cx + mx_css * dpr * (oldScale - newScale)
                = state.cx + (e.clientX - W_css/2) * dpr * 2 * (oldView - newView) / (H_css * dpr)
                = state.cx + (e.clientX - W_css/2) * 2 * (oldView - newView) / H_css

So the dpr cancels out. Good:
```js
state.cx += (e.clientX - canvas.clientWidth/2) * (2 * oldView - 2 * newView) / canvas.clientHeight;
```

Same for y but with `(canvas.clientHeight/2 - e.clientY)`:
```js
state.cy += (canvas.clientHeight/2 - e.clientY) * (2 * oldView - 2 * newView) / canvas.clientHeight;
```

Both use canvas.clientHeight (CSS pixels). ✓ 

Now the resize handling: when window resizes, we change canvas.width/height (device pixels) and canvas.style.width/height (CSS pixels). After resize, needsRender = true.

Hmm wait, I realize there's a subtle issue with `canvas.clientWidth` after setting `canvas.width` and `canvas.style.width`. canvas.width is the "drawing surface" size, while canvas.style.width is the rendered size. They differ. clientWidth is the rendered size. So clientHeight = CSS height. Width of drawing surface = canvas.width (device pixels).

Setting canvas.width: this sets the drawing buffer size. Setting canvas.style.width: this sets the CSS width. canvas.width and canvas.height should be canvas.clientWidth * dpr for crisp rendering.

OK I'm overthinking. Just to recap:
```js
function resize() {
  const dpr = Math.min(window.devicePixelRatio || 1, 2);
  const w = Math.floor(window.innerWidth * dpr);
  const h = Math.floor(window.innerHeight * dpr);
  if (canvas.width !== w || canvas.height !== h) {
    canvas.width = w;
    canvas.height = h;
    canvas.style.width = window.innerWidth + 'px';
    canvas.style.height = window.innerHeight + 'px';
    needsRender = true;
  }
}
```

Good. CSS sizes match window inner size.

Right, time to commit and write the full thing.

One final touch: I want to show the user the current "mouse position in mandelbrot coords" too, since that's super useful for fractal exploration. Add to HUD:

```html
<div class="row small"><span>mouse</span><span id="mx">-0.50</span></div>
<div class="row small"><span></span><span id="my">0.00</span></div>
```

Tracking mouse and updating HUD: throttle updates with requestAnimationFrame or just update on mousemove directly. Direct is fine, mouse moves are bounded.

```js
canvas.addEventListener('mousemove', e => {
  updateMouseCoords(e);
  // ... drag handler ...
});

function updateMouseCoords(e) {
  const h = canvas.clientHeight;
  const w = canvas.clientWidth;
  const scale = 2 * state.view / h;
  const mx = state.cx + (e.clientX - w/2) * scale;
  const my = state.cy + (h/2 - e.clientY) * scale;
  $('mx').textContent = mx.toFixed(10);
  $('my').textContent = my.toFixed(10);
}
```

Nice.

But for dragging, I use `mousemove` on window not canvas, so I need to track from there too. Let me consolidate:

```js
function onPointerMove(e) {
  if (dragging) {
    // pan
  }
  updateMouseCoords(e);
}
```

Yeah. attach to both mousedown (on canvas) and mousemove (on window or canvas), and have the helper handle everything.

OK let me write the code in full now. No more deliberation.

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Mandelbrot Explorer</title>
<style>
  *, *::before, *::after { box-sizing: border-box; }
  html, body { margin: 0; padding: 0; height: 100%; width: 100%; overflow: hidden; background: #000; }
  body {
    font: 13px/1.4 ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    color: #e7e7e7;
    -webkit-font-smoothing: antialiased;
  }
  canvas#c { display: block; width: 100vw; height: 100vh; cursor: grab; touch-action: none; }
  canvas#c.dragging { cursor: grabbing; }

  .hud { position: fixed; pointer-events: none; user-select: none; }
  .hud.tl { top: 14px; left: 14px; }
  .hud.tr { top: 14px; right: 14px; pointer-events: auto; }
  .hud.bl { bottom: 14px; left: 14px; }

  .pill {
    background: rgba(18,18,28,0.55);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255,255,255,0.08);
    border-radius: 10px;
    padding: 10px 12px;
    text-shadow: 0 1px 2px rgba(0,0,0,0.7);
    min-width: 170px;
  }
  .pill .title { font-weight: 600; letter-spacing: 0.02em; margin-bottom: 4px; }
  .pill .row { display: flex; justify-content: space-between; gap: 8px; font-feature-settings: "tnum"; margin-top: 2px; font-size: 11px; }
  .pill .row span:first-child { opacity: 0.55; min-width: 56px; }
  .pill .row span:last-child { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }

  .panel {
    background: rgba(18,18,28,0.55);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    border: 1px solid rgba(255,255,255,0.08);
    border-radius: 10px;
    padding: 12px;
    min-width: 240px;
  }
  .panel .ph {
    display: flex; justify-content: space-between; align-items: center;
    font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase;
    opacity: 0.5; margin-bottom: 8px;
  }
  .panel .ctl { display: flex; align-items: center; gap: 10px; margin: 7px 0; font-size: 12px; }
  .panel .ctl label { flex: 0 0 auto; opacity: 0.85; min-width: 60px; }
  .panel .ctl .val { font-feature-settings: "tnum"; min-width: 36px; text-align: right; opacity: 0.95; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
  input[type="range"] { flex: 1; accent-color: #ff8ad8; height: 4px; }
  
  .btnrow { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
  .btn {
    cursor: pointer; user-select: none;
    background: rgba(255,255,255,0.05);
    border: 1px solid rgba(255,255,255,0.1);
    color: inherit; font: inherit; font-size: 12px;
    padding: 6px 10px; border-radius: 6px;
    transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
  }
  .btn:hover { background: rgba(255,255,255,0.12); }
  .btn:active { transform: translateY(1px); }
  .btn.active { background: rgba(255,138,216,0.18); border-color: rgba(255,138,216,0.4); color: #ffd0ee; }

  .legend { font-size: 11px; opacity: 0.55; padding: 6px 10px; border-radius: 8px; background: rgba(18,18,28,0.35); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
  .legend .kbd { display: inline-block; padding: 1px 5px; margin: 0 2px; font-size: 10px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05); font-family: ui-monospace, monospace; }

  @media (max-width: 600px) {
    .hud.tr { top: auto; bottom: 14px; right: 14px; }
    .hud.bl { display: none; }
  }
</style>
</head>
<body>
<canvas id="c"></canvas>

<div class="hud tl">
  <div class="pill">
    <div class="title">Mandelbrot Explorer</div>
    <div class="row"><span>center x</span><span id="cx">-0.500000</span></div>
    <div class="row"><span>center y</span><span id="cy">0.000000</span></div>
    <div class="row"><span>zoom</span><span id="zo">1.00×</span></div>
    <div class="row"><span>iterations</span><span id="it">700</span></div>
    <div class="row"><span>mouse x</span><span id="mx">-0.500000</span></div>
    <div class="row"><span>mouse y</span><span id="my">0.000000</span></div>
  </div>
</div>

<div class="hud tr">
  <div class="panel">
    <div class="ph"><span>controls</span><span>·</span></div>
    <div class="ctl">
      <label for="iter">iter</label>
      <input type="range" id="iter" min="50" max="1500" step="25" value="700">
      <span class="val" id="iterv">700</span>
    </div>
    <div class="ctl">
      <label for="pal">hue</label>
      <input type="range" id="pal" min="0" max="100" step="1" value="20">
      <span class="val" id="palv">20</span>
    </div>
    <div class="ctl">
      <label for="cyc">cycle</label>
      <input type="range" id="cyc" min="0" max="20" step="1" value="6">
      <span class="val" id="cycv">6</span>
    </div>
    <div class="btnrow">
      <button class="btn" id="reset">reset</button>
      <button class="btn" id="anim">shimmer</button>
      <button class="btn" id="invert">invert</button>
    </div>
  </div>
</div>

<div class="hud bl">
  <span class="legend">
    <span class="kbd">drag</span> pan &nbsp;·&nbsp;
    <span class="kbd">scroll</span> zoom &nbsp;·&nbsp;
    <span class="kbd">dbl-click</span> reset &nbsp;·&nbsp;
    <span class="kbd">±</span> zoom &nbsp;·&nbsp;
    <span class="kbd">↑↓←→</span> pan
  </span>
</div>

<script>
(() => {
  const $ = id => document.getElementById(id);
  const canvas = $('c');
  const gl = canvas.getContext('webgl', { antialias: false, preserveDrawingBuffer: false });
  if (!gl) {
    document.body.innerHTML = '<div style="padding:20px;color:#fff;font:14px sans-serif">WebGL is required for this demo.</div>';
    return;
  }

  function compile(type, src) {
    const s = gl.createShader(type);
    gl.shaderSource(s, src);
    gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
      const log = gl.getShaderInfoLog(s);
      console.error(log);
      throw new Error('Shader compile failed: ' + log);
    }
    return s;
  }
  function makeProgram(vs, fs) {
    const p = gl.createProgram();
    gl.attachShader(p, compile(gl.VERTEX_SHADER, vs));
    gl.attachShader(p, compile(gl.FRAGMENT_SHADER, fs));
    gl.linkProgram(p);
    if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
      const log = gl.getProgramInfoLog(p);
      console.error(log);
      throw new Error('Program link failed: ' + log);
    }
    return p;
  }

  const vsSrc = `attribute vec2 a_pos; void main(){ gl_Position = vec4(a_pos, 0.0, 1.0); }`;

  const fsSrc = `
    precision highp float;
    uniform vec2 u_resolution;
    uniform vec2 u_center;
    uniform float u_view;
    uniform float u_palOff;
    uniform float u_cycle;
    uniform float u_invert;
    uniform int u_iter;
    const int MAX_LOOP = 2000;

    vec3 palette(float t) {
      vec3 a = vec3(0.55, 0.50, 0.55);
      vec3 b = vec3(0.50, 0.50, 0.50);
      vec3 c = vec3(1.00, 0.95, 0.85);
      vec3 d = vec3(0.00, 0.20, 0.45);
      return a + b * cos(6.28318530718 * (c * t + d));
    }

    void main() {
      vec2 fragCoord = gl_FragCoord.xy;
      float scale = 2.0 * u_view / u_resolution.y;
      vec2 c = (fragCoord - 0.5 * u_resolution) * scale + u_center;

      vec2 z = vec2(0.0);
      float n = 0.0;
      bool escaped = false;

      for (int i = 0; i < MAX_LOOP; i++) {
        if (i >= u_iter) break;
        z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
        if (dot(z, z) > 4.0) { escaped = true; break; }
        n += 1.0;
      }

      if (!escaped) {
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
        return;
      }

      float sn = n + 1.0 - log(log(length(z))) / log(2.0);
      float t = sn * u_cycle / float(u_iter) + u_palOff;

      vec3 col = palette(t);
      col = mix(col, 1.0 - col, u_invert);
      gl_FragColor = vec4(col, 1.0);
    }
  `;

  let program;
  try {
    program = makeProgram(vsSrc, fsSrc);
  } catch (e) {
    document.body.innerHTML = '<div style="padding:20px;color:#fff;font:14px sans-serif">Unable to initialize WebGL shader.<br><br>'+ e.message +'</div>';
    return;
  }
  gl.useProgram(program);

  const loc = {
    aPos: gl.getAttribLocation(program, 'a_pos'),
    uResolution: gl.getUniformLocation(program, 'u_resolution'),
    uCenter: gl.getUniformLocation(program, 'u_center'),
    uView: gl.getUniformLocation(program, 'u_view'),
    uPalOff: gl.getUniformLocation(program, 'u_palOff'),
    uCycle: gl.getUniformLocation(program, 'u_cycle'),
    uInvert: gl.getUniformLocation(program, 'u_invert'),
    uIter: gl.getUniformLocation(program, 'u_iter'),
  };
  if (loc.aPos < 0) {
    console.error('aPos location not found');
    return;
  }

  const buf = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buf);
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 1,-1, -1,1, 1,1]), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(loc.aPos);
  gl.vertexAttribPointer(loc.aPos, 2, gl.FLOAT, false, 0, 0);

  const initial = { cx: -0.5, cy: 0, view: 1.25, iter: 700, palOff: 0.20, cycle: 6, invert: 0 };
  const state = { ...initial };
  let needsRender = true;
  let shimmer = false;

  function renderIfNeeded() {
    if (!needsRender) return;
    needsRender = false;
    gl.viewport(0, 0, canvas.width, canvas.height);
    gl.uniform2f(loc.uResolution, canvas.width, canvas.height);
    gl.uniform2f(loc.uCenter, state.cx, state.cy);
    gl.uniform1f(loc.uView, state.view);
    gl.uniform1f(loc.uPalOff, state.palOff);
    gl.uniform1f(loc.uCycle, state.cycle);
    gl.uniform1f(loc.uInvert, state.invert);
    gl.uniform1i(loc.uIter, state.iter);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    updateHUD();
  }

  function updateHUD() {
    $('cx').textContent = state.cx.toFixed(10);
    $('cy').textContent = state.cy.toFixed(10);
    $('zo').textContent = formatZoom(state.view);
    $('it').textContent = state.iter;
  }

  function formatZoom(v) {
    const z = initial.view / v;
    if (z >= 1000) return z.toFixed(0) + '×';
    if (z >= 100) return z.toFixed(1) + '×';
    if (z >= 10) return z.toFixed(2) + '×';
    return z.toFixed(2) + '×';
  }

  function clamp(x, lo, hi) { return Math.min(hi, Math.max(lo, x)); }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(window.innerWidth * dpr));
    const h = Math.max(1, Math.floor(window.innerHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;
      canvas.height = h;
      canvas.style.width = window.innerWidth + 'px';
      canvas.style.height = window.innerHeight + 'px';
      needsRender = true;
    }
  }

  window.addEventListener('resize', resize);
  resize();

  // Mouse / touch handling
  let drag = null;
  let pinch = null;

  function pxToMandel(clientX, clientY) {
    const h = canvas.clientHeight;
    const scale = 2 * state.view / h;
    return {
      x: state.cx + (clientX - canvas.clientWidth / 2) * scale,
      y: state.cy + (h / 2 - clientY) * scale,
    };
  }

  function updateMouseDisplay(clientX, clientY) {
    const p = pxToMandel(clientX, clientY);
    $('mx').textContent = p.x.toFixed(10);
    $('my').textContent = p.y.toFixed(10);
  }

  canvas.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return;
    drag = { x: e.clientX, y: e.clientY };
    canvas.classList.add('dragging');
    e.preventDefault();
  });

  window.addEventListener('mousemove', (e) => {
    if (drag) {
      const dx = e.clientX - drag.x;
      const dy = e.clientY - drag.y;
      drag.x = e.clientX;
      drag.y = e.clientY;
      const scale = 2 * state.view / canvas.clientHeight;
      state.cx -= dx * scale;
      state.cy -= dy * scale;
      needsRender = true;
    }
    updateMouseDisplay(e.clientX, e.clientY);
  });

  window.addEventListener('mouseup', () => {
    if (drag) {
      drag = null;
      canvas.classList.remove('dragging');
    }
  });

  canvas.addEventListener('dblclick', (e) => {
    e.preventDefault();
    reset();
  });

  canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const factor = Math.exp(e.deltaY * 0.0015);
    const oldView = state.view;
    const newView = clamp(oldView * factor, 1e-15, 1e3);
    const mx = e.clientX - canvas.clientWidth / 2;
    const my = canvas.clientHeight / 2 - e.clientY;
    const k = 2 * (oldView - newView) / canvas.clientHeight;
    state.cx += mx * k;
    state.cy += my * k;
    state.view = newView;
    needsRender = true;
  }, { passive: false });

  // Touch
  canvas.addEventListener('touchstart', (e) => {
    if (e.touches.length === 1) {
      drag = { id: e.touches[0].identifier, x: e.touches[0].clientX, y: e.touches[0].clientY };
      pinch = null;
    } else if (e.touches.length === 2) {
      drag = null;
      const t0 = e.touches[0], t1 = e.touches[1];
      const cx = (t0.clientX + t1.clientX) / 2;
      const cy = (t0.clientY + t1.clientY) / 2;
      const dist = Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
      pinch = { cx, cy, dist, view: state.view };
    }
    e.preventDefault();
  }, { passive: false });

  canvas.addEventListener('touchmove', (e) => {
    if (e.touches.length === 1 && drag && e.touches[0].identifier === drag.id) {
      const t = e.touches[0];
      const dx = t.clientX - drag.x;
      const dy = t.clientY - drag.y;
      drag.x = t.clientX;
      drag.y = t.clientY;
      const scale = 2 * state.view / canvas.clientHeight;
      state.cx -= dx * scale;
      state.cy -= dy * scale;
      needsRender = true;
    } else if (e.touches.length === 2 && pinch) {
      const t0 = e.touches[0], t1 = e.touches[1];
      const cx = (t0.clientX + t1.clientX) / 2;
      const cy = (t0.clientY + t1.clientY) / 2;
      const dist = Math.hypot(t0.clientX - t1.clientX, t0.clientY - t1.clientY);
      const factor = pinch.dist / dist;
      const oldView = state.view;
      const newView = clamp(oldView * factor, 1e-15, 1e3);
      const mx = pinch.cx - canvas.clientWidth / 2;
      const my = canvas.clientHeight / 2 - pinch.cy;
      const k = 2 * (oldView - newView) / canvas.clientHeight;
      state.cx += mx * k;
      state.cy += my * k;
      state.view = newView;
      needsRender = true;
    }
    e.preventDefault();
  }, { passive: false });

  canvas.addEventListener('touchend', (e) => {
    if (e.touches.length === 0) {
      drag = null; pinch = null;
    } else if (e.touches.length === 1) {
      const t = e.touches[0];
      drag = { id: t.identifier, x: t.clientX, y: t.clientY };
      pinch = null;
    }
  });

  // Keyboard
  window.addEventListener('keydown', (e) => {
    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
    let handled = false;
    switch (e.key) {
      case 'ArrowLeft':  state.cx -= state.view * 0.18; handled = true; break;
      case 'ArrowRight': state.cx += state.view * 0.18; handled = true; break;
      case 'ArrowUp':    state.cy += state.view * 0.18; handled = true; break;
      case 'ArrowDown':  state.cy -= state.view * 0.18; handled = true; break;
      case '+': case '=': {
        const oldView = state.view;
        const newView = clamp(oldView * 0.8, 1e-15, 1e3);
        state.view = newView;
        handled = true;
        break;
      }
      case '-': case '_': {
        const oldView = state.view;
        const newView = clamp(oldView * 1.25, 1e-15, 1e3);
        state.view = newView;
        handled = true;
        break;
      }
      case 'r': case 'R': reset(); handled = true; break;
      case 'i': case 'I': toggleInvert(); handled = true; break;
      case ' ': toggleShimmer(); handled = true; break;
    }
    if (handled) {
      e.preventDefault();
      needsRender = true;
    }
  });

  // UI controls
  function syncSlider(id, valEl, key, scale) {
    const el = $(id);
    el.addEventListener('input', () => {
      const v = parseFloat(el.value);
      state[key] = scale ? v / scale : v;
      $(valEl).textContent = el.value;
      if (key === 'palOff') {
        // Disable shimmer if user manually changed hue
        shimmer = false;
        $('anim').classList.remove('active');
      }
      needsRender = true;
    });
  }
  syncSlider('iter', 'iterv', 'iter');
  syncSlider('pal', 'palv', 'palOff', 100);
  syncSlider('cyc', 'cycv', 'cycle');

  $('reset').addEventListener('click', reset);
  $('invert').addEventListener('click', toggleInvert);
  $('anim').addEventListener('click', toggleShimmer);

  function reset() {
    state.cx = initial.cx;
    state.cy = initial.cy;
    state.view = initial.view;
    state.iter = initial.iter;
    state.palOff = initial.palOff;
    state.cycle = initial.cycle;
    state.invert = initial.invert;
    $('iter').value = String(state.iter);
    $('iterv').textContent = String(state.iter);
    $('pal').value = String(Math.round(state.palOff * 100));
    $('palv').textContent = String(Math.round(state.palOff * 100));
    $('cyc').value = String(state.cycle);
    $('cycv').textContent = String(state.cycle);
    needsRender = true;
  }

  function toggleInvert() {
    state.invert = state.invert ? 0 : 1;
    $('invert').classList.toggle('active', !!state.invert);
    needsRender = true;
  }

  function toggleShimmer() {
    shimmer = !shimmer;
    $('anim').classList.toggle('active', shimmer);
  }

  updateHUD();

  function loop() {
    if (shimmer) {
      state.palOff += 0.0008;
      if (state.palOff >= 1) state.palOff -= 1;
      $('palv').textContent = String(Math.round(state.palOff * 100));
      needsRender = true;
    }
    renderIfNeeded();
    requestAnimationFrame(loop);
  }
  requestAnimationFrame(loop);
})();
</script>
</body>
</html>
```

Wait, `palette(t)` I wrote includes both `a` and `b` as `vec3(0.5)` and `vec3(...)` with three params which I split on lines. Let me double check that GLSL ES 1.00 supports `vec3(0.5, 0.5, 0.5)` syntax — yes.

Let me also verify: does `cos(6.28318530718 * (c * t + d))` work with broadcasting? vec3 + vec3 = elementwise. vec3 * float = scale. 6.28... * vec3 = vec3. vec3 * t (float) = vec3. cos(vec3) = elementwise cos. So it should give vec3 out.

Hmm, `vec3 c = vec3(1.00, 0.95, 0.85);` and then `c * t` where t is a float: yes, broadcasts.

Let me check the palette — it should make nice Mandelbrot art. The IQ palette I'm using gives a subtle gradient with cycles. With cycle=6 and the offset, we get 6 color cycles as we go from inside to far out. 

Wait, the existing palette might not look that great. Let me think of a better palette. Standard approaches:
1. IQ palette: a + b*cos(2π(c*t+d)). Tunable.
2. Multi-color stops interpolated.
3. Custom rainbow with palette() function.

Let me adjust to make it more vivid:
```
a = vec3(0.5, 0.5, 0.5)
b = vec3(0.5, 0.5, 0.5)
c = vec3(1.0, 1.0, 1.0)
d = vec3(0.0, 0.33, 0.67)  // blue-ish cycle
```

This gives a rainbow-like cycle. Let me use that.

Or for richer cyans and magentas:
```
d = vec3(0.0, 0.1, 0.2)
```

Or fire:
```
a = vec3(0.5, 0.2, 0.1)
b = vec3(0.5, 0.3, 0.1)
c = vec3(1.0, 0.5, 0.2)
d = vec3(0.0, 0.1, 0.2)
```

Or electric blue/purple:
```
a = vec3(0.3, 0.2, 0.4)
b = vec3(0.5, 0.3, 0.6)
c = vec3(1.0, 1.0, 1.0)
d = vec3(0.0, 0.1, 0.2)
```

Hmm let me go with a fiery-icy palette that feels Mandelbrot-y:
```
a = vec3(0.6, 0.4, 0.4)
b = vec3(0.4, 0.4, 0.5)
c = vec3(1.0, 0.7, 0.4)
d = vec3(0.0, 0.15, 0.20)
```

Hmm, let me just try multiple options. The user can adjust hue/cycle to taste. So a default that looks balanced is good.

Going with:
```
a = vec3(0.5, 0.5, 0.5)
b = vec3(0.5, 0.5, 0.5)
c = vec3(1.0, 1.0, 1.0)
d = vec3(0.0, 0.30, 0.60)
```

That'll be rainbow-tinted. Cycle=6, palOff=0.2.

Actually, I realize people often use:
```
a = vec3(0.5, 0.5, 0.5)
b = vec3(0.5, 0.5, 0.5)  
c = vec3(1.0, 1.0, 0.5)
d = vec3(0.8, 0.9, 0.3)
```

This shifts color toward orange. Let me try:
- a = vec3(0.5, 0.5, 0.5)
- b = vec3(0.5, 0.5, 0.5)
- c = vec3(1.0, 1.0, 0.5)
- d = vec3(0.0, 0.10, 0.20)

This gives a cosine-based palette starting at red, going through green, blue, back. It looks nice and "fractal-y".

OK I'll go with this. The user can adjust hue offset to shift.

Let me finalize the code now and write it. Let me think once more...

Actually, before I finalize, let me trace through once more:
- Initial view: center=(-0.5, 0), view=1.25. So y range: -1.25 to +1.25, total 2.5 units. ✓ Mandelbrot fits.
- For 16:9 aspect window: x range: cy ± (W/2) * scale = -0.5 ± (W/2) * (2*1.25/H) = -0.5 ± W/H * 1.25 ≈ -0.5 ± 1.78 * 1.25 = -0.5 ± 2.22 = -2.72 to 1.72. ✓ Mandelbrot fits.

Initial zoom display: initial.view/view = 1.25/1.25 = 1.00×. ✓

After wheel zoom in by 2x (factor=0.5): view = 1.25 * 0.5 = 0.625. Zoom display: 1.25/0.625 = 2.00×. ✓ 

Mouse wheel: zoom in is conventional when scrolling up (deltaY < 0). Let me check my formula: `factor = Math.exp(e.deltaY * 0.0015)`. deltaY < 0 => factor < 1 => view = oldView * factor = oldView * (something < 1) => view decreases => zoom increases. ✓ 

OK let me write the file.

Actually wait, one tiny detail. When I do `state.view * 0.18` for arrow pan, I get pixel-equivalent mandelbrot units of 0.18 * view. That moves the view by 18% of its half-height per arrow press. Reasonable. But for keyboard repeat, that could be too fast. Eh, fine for now.

Let me also handle the `e.target.tagName` check — ensure keys work on body but not while editing input. Already have that.

Actually, when the page loads, e.target = body or html. tagName would be "BODY" or "HTML" — not INPUT. ✓

When focused on the slider, e.target is INPUT. We skip. ✓

Alright, I think I'm ready. Let me write the file.

One last thought: the dpr cap. If user has a Retina display (dpr=2), we cap at 2. If user has dpr=3 (some Android phones), cap at 2 for performance. OK.

Also, I should debounce resize for orientation change on phones. But rAF handles that since we call renderIfNeeded which won't rerender unless needsRender=true.

Let me write.

Oh, hmm. The renderIfNeeded is called inside the loop. But the loop calls requestAnimationFrame(loop), so each frame we may render if needed. That's continuous — wastes some CPU but is simple. Modern devices handle this fine.

Let me consider the very first frame: needsRender=true, then false after first render. CSS loads, page renders. ✓

Final issue: `requestAnimationFrame` is called inside `loop`, which is called by `requestAnimationFrame(loop)`. There's a boot issue if rAF doesn't fire for some reason. Should be safe.

OK I'll write the file now.

Oh, I realize I should double check `gl.uniform1i(loc.uIter, state.iter)` works for negative or too-large values. State.iter is set via slider min=50 max=1500. Should be fine.

OK writing.

Wait one more. The `MAX_LOOP = 2000` in shader. With WebGL 1, integer constants must be at least 1. 2000 is fine. Slider max=1500, MAX_LOOP=2000 leaves buffer. Good.

Hmm wait, I need to also handle the case where user moves slider past 2000. Let me cap slider max to 1500. Already done.

Final question: performance considerations. With MAX_LOOP=2000, early-break is helpful for exterior pixels that escape quickly. Interior points will run all 1500 iters (max). At default 700, interior runs 700. On a 1920x1080 canvas with dpr=2 = ~8M pixels at default, average iter ~50 (most points escape fast), that's 400M iterations. At 50GFlops GPUs typical, that's ~8ms per frame. Should be 60fps no problem. ✓

OK writing.

Wait actually, I realize: with dpr=2, a 1920x1080 window has the canvas at 3840x2160 = ~8.3M pixels. Per pixel an avg iter of 50 = 415M iter. Per iter takes ~5 FLOPS so ~2 GFLOPs, that's negligible on a modern GPU. Should be 60fps. Even 30fps for high-iter deep zoom.

OK go.

One more nit: I should probably round the displayed coordinates to fewer digits when zoom is shallow (avoids -0.500000000000 for low zoom). Let me adjust `toFixed` based on zoom level:

```js
function updateHUD() {
  const z = initial.view / state.view;
  const digits = Math.max(2, Math.min(10, Math.ceil(Math.log10(1/z * 1000))));
  // smaller z (high zoom) => more digits
  $('cx').textContent = state.cx.toFixed(digits);
  ...
}
```

Eh, just use 10 digits always. Hmm, at low zoom that's silly. At zoom=1, cx=-0.5, displaying -0.5000000000 is fine, just shows precision. At high zoom, more digits matter. Let me dynamically choose:

```js
function digitsForView() {
  const precision = state.view * 1000; // mandelbrot units per zoom-relevance
  if (precision > 1) return 4;
  if (precision > 0.01) return 7;
  if (precision > 0.0001) return 10;
  return 14;
}
```

Hmm let me think. When user zooms so that 1 pixel = state.view / 250 mandelbrot units. We want to display coord to roughly pixel-level precision: state.view / 250.

For view=1.25 (initial): 1 pixel = 0.005, so 3-4 digits enough.
For view=1.25/100 (zoom 100x): 1 pixel = 5e-5, so 7-8 digits.
For view=1.25/10000: 1 pixel = 5e-7, so 10-11 digits.

Roughly: digits = 4 + log10(zoom) ≈ log10(1/view * 1000). Let me cap at 14 (max for double precision is ~15-16).

```js
const d = Math.min(14, Math.max(4, Math.floor(4 + Math.log10(initial.view / state.view) + 3)));
```

Eh, let me just write simple version:

```js
function precisionDigits() {
  const r = Math.log10(state.view) - Math.log10(initial.view); // negative when zoomed in
  return Math.max(2, Math.min(15, Math.round(-r * 1.7) + 4));
}
```

Test: view=1.25, log10(1.25)=0.097, log10(1.25)=0.097, r=0, returns 4.
view=0.001, log10(0.001)=-3, log10(1.25)=0.097, r=-3.097, -r*1.7+4 ≈ 5.265+4 ≈ 9.265, round=9. Hmm at zoom ~1250x, 9 digits sounds about right.
view=1e-6 (zoom ~1.25e6), r=-6.097, -r*1.7+4 ≈ 10.4+4=14.4, capped at 15. OK.

Let me just use this. And for mouse, same digits (or one less since live mouse). Eh, just use it.

Actually, I'll simplify: always toFixed(10). User can read it. Let me just keep it simple. If at low zoom it shows many zeros, that's fine. At high zoom it shows the relevant digits. Looks consistent.

OK, no, let me make it adaptive. It's a nice touch.

I'll write it as:
```js
function digits() {
  const z = initial.view / state.view;
  return Math.max(2, Math.min(15, Math.round(Math.log10(z) * 1.5 + 4)));
}
```
- z=1: digits=4
- z=10: digits=5.5→6
- z=100: digits=7
- z=1000: digits=8.5→9
- z=10000: digits=10

That works. Let me apply it to cx, cy, mx, my.

```js
function updateHUD() {
  const d = digits();
  $('cx').textContent = state.cx.toFixed(d);
  $('cy').textContent = state.cy.toFixed(d);
  $('zo').textContent = formatZoom(state.view);
  $('it').textContent = state.iter;
}

function updateMouseDisplay(clientX, clientY) {
  const p = pxToMandel(clientX, clientY);
  const d = digits();
  $('mx').textContent = p.x.toFixed(d);
  $('my').textContent = p.y.toFixed(d);
}
```

OK final, writing now.

Alright I'm going to write the final file. Let me check one more time through everything:

1. ✅ Single-file HTML, no external resources
2. ✅ WebGL fullscreen renderer
3. ✅ Smooth coloring via log(log(length(z)))/log(2)
4. ✅ Click-drag pan
5. ✅ Wheel zoom with mouse-anchor preservation
6. ✅ Touch support (single touch drag, two-finger pinch zoom)
7. ✅ Dbl-click reset
8. ✅ Keyboard shortcuts (arrows, +/-, R, I, space)
9. ✅ Adaptive precision HUD
10. ✅ Reset, shimmer, invert buttons
11. ✅ Iteration, hue, cycle sliders

Going to write now. Final code:

Looking at this request, I need to build a single-file HTML Mandelbrot explorer with: - WebGL fragment-shader rendering (fast enough for smooth interaction) - Smooth (non-banded) coloring via the log/log escape-time formula - Click-drag pan, mouse-wheel zoom anchored on cursor, plus touch + keyboard support - A clean dark UI with HUD showing live coordinates and controls Let me write it:

write /workspace/index.html (20304 bytes)
toolResult

Successfully wrote 20304 bytes to /workspace/index.html

assistant
thinking
Let me verify the file was created and double-check the content is sensible.

Let me verify the file is in place and structurally sound:

bash {"command":"ls -la /workspace/index.html && echo \"---\" && wc -l /workspace/index.html && echo \"---\" && head -5 /workspace/index.html &&
toolResult

-rw-r--r-- 1 node node 20327 Jul 22 16:49 /workspace/index.html --- 636 /workspace/index.html --- <!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> ... requestAnimationFrame(tick); })(); </script> </body> </html>

assistant
thinking
File is in place, single self-contained file. Let me give a brief summary of what's in it.

Done — `index.html` is in `/workspace/` (~20 KB, fully self-contained, no network calls). **What it does:** - **WebGL fragment shader** renders the Mandelbrot set as a full-screen pass — fast enough to stay smooth while panning/zooming. - **Smooth coloring** via the standard `n + 1 - log(log(|z|)) / log(2)` formula, plus an Inigo Quilez-style cosine palette so you get continuous gradients instead of banded iterations. - **Click-and-drag pans**; **mouse-wheel zooms anchored on the cursor** (the mandelbrot point under the mouse stays put as you zoom). A normalized delta factor handles both pixel and line wheel modes. - **Touch support**: single-finger drag = pan, two-finger pinch = zoom around the pinch midpoint. - **Keyboard**: arrow keys pan, `+`/`−` zoom, `R` resets, `I` inverts colors, `Space` toggles shimmer; double-click on the canvas resets too. - **HUD**: live center coordinates, zoom multiplier, iteration count, and the mandelbrot coordinates under the cursor — precision auto-scales with zoom (4 digits at 1×, up to 14 at deep zoom). - **Controls panel** (top-right): iteration slider (50–1500), hue offset, color-cycle count, plus `reset` / `shimmer` / `invert` toggles. Layout collapses to a bottom panel on narrow screens. - **Devicepixelratio-aware** rendering (capped at 2× for perf) and a subtle gamma on the color output so dark interior regions don't crush to black. Just open `index.html` in a browser with WebGL and start exploring.

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

modelminimax-m3
vendorMiniMax
harnesspi-agent
generated2026-07-22

EVALUATION

4/5 working

Smooth-colored Mandelbrot renders; drag pans, wheel zooms, and extra controls (hue/invert) also work well.

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.0%
on click0.0%
on drag60.2%
on wheel12.1%
on enter + space92.4%
on arrow keys91.2%
on w/a/s/d0.2%
frame spread37.1 / 255
console errors0
js errors none

METRICS

runtime259.6s
tokens in38k
tokens out37k
cached40k
cost / run$0.0577