DEV Community

Fabian Quijosaca
Fabian Quijosaca

Posted on

Mandelbrot Set in JS - Smooth Scroll Zoom & Fixing Floating-Point Precision

This is a follow-up to Mandelbrot Set in JS - Zoom In.
In that article we built a Mandelbrot renderer using Canvas and Web Workers, with click-to-zoom.
This post covers what broke after ~16 zooms, why it broke (floating-point precision),
and how we replaced click zoom with a smooth scroll-based zoom that also lets you zoom back out.


The Problem: Everything Turns Black After ~16 Clicks

If you played with the previous demo long enough, you noticed something strange: after zooming in about 16 times, the fractal starts looking pixelated, blocky, and eventually the entire canvas turns solid black.

This isn't a bug in the Mandelbrot math. The set is infinitely detailed, there's always more structure to see. The problem is in how computers store decimal numbers.

Root Cause: JavaScript Numbers Have Limited Precision

JavaScript (like most languages) stores all numbers as 64-bit IEEE 754 doubles. This is just the standard format computers use for decimal numbers, and it gives you about 15 to 17 significant digits of precision. That sounds like a lot, but zoom burns through those digits very fast.

How the old zoom worked

Each click zoomed to a window of 2 × ZOOM_FACTOR × canvas_width pixels centered on the click point. With ZOOM_FACTOR = 0.1, each zoom reduced the visible range to 20% of the previous range:

const zfw = WIDTH * ZOOM_FACTOR;  // 800 * 0.1 = 80px on each side
REAL_SET = {
  start: getRelativePoint(e.pageX - canvas.offsetLeft - zfw, WIDTH, REAL_SET),
  end:   getRelativePoint(e.pageX - canvas.offsetLeft + zfw, WIDTH, REAL_SET),
};
Enter fullscreen mode Exit fullscreen mode

The coordinate range after N clicks shrinks like this:

range_after_N = initial_range × 0.2^N
Enter fullscreen mode Exit fullscreen mode
Clicks Real axis range
0 3.0 (from -2 to 1)
5 ~0.00077
10 ~2.4 × 10⁻⁷
15 ~7.5 × 10⁻¹²
16 ~1.5 × 10⁻¹²

At click 15, the range is 7.5e-12. If your center is around -0.7, the coordinates look like:

start: -0.700000000003750
end:   -0.700000000003751
Enter fullscreen mode Exit fullscreen mode

Those two numbers share 15 digits. With only 15 to 17 digits of total precision, the difference between adjacent pixels becomes too small to represent. Every pixel ends up mapping to the same value. Result: a grid of identical colors, pixelation, black.

This is called catastrophic cancellation: when you subtract two numbers that are almost the same, you lose all the useful digits.

The Fix, Part 1: Replace Click with Scroll Zoom

The first change is switching from click to wheel (scroll). This gives us:

  • Zoom in (scroll up) and zoom out (scroll down) with the same gesture
  • Smooth, step-by-step control over the zoom level
  • The zoom always centers on the cursor position

Here is the complete new listener:

const ZOOM_FACTOR = 0.8; // each scroll step = 80% of current range (zoom in)
const MIN_RANGE = 1e-12; // safety limit, stop before precision breaks down

const startListeners = () => {
  canvas.addEventListener('wheel', (e) => {
    e.preventDefault();
    const zoomIn = e.deltaY < 0;
    const factor = zoomIn ? ZOOM_FACTOR : 1 / ZOOM_FACTOR;

    const realRange = REAL_SET.end - REAL_SET.start;
    const imagRange = IMAGINARY_SET.end - IMAGINARY_SET.start;
    const newRealRange = realRange * factor;
    const newImagRange = imagRange * factor;

    // Stop zooming in before precision collapses
    if (newRealRange < MIN_RANGE || newImagRange < MIN_RANGE) return;

    // Map cursor pixel to a point in the complex plane
    const mouseX = e.pageX - canvas.offsetLeft;
    const mouseY = e.pageY - canvas.offsetTop;
    const centerReal = getRelativePoint(mouseX, WIDTH, REAL_SET);
    const centerImag = getRelativePoint(mouseY, HEIGHT, IMAGINARY_SET);

    REAL_SET = {
      start: centerReal - newRealRange / 2,
      end:   centerReal + newRealRange / 2,
    };
    IMAGINARY_SET = {
      start: centerImag - newImagRange / 2,
      end:   centerImag + newImagRange / 2,
    };

    Mandelbrot();
  }, { passive: false }); // passive: false is required to call e.preventDefault()
};
Enter fullscreen mode Exit fullscreen mode

Let's go through each decision:

e.preventDefault() + { passive: false }

By default, browsers treat wheel events as passive for performance, assuming you won't stop the default scroll behavior. We need to prevent the page from scrolling while the user zooms the fractal, so we have to opt out. Without { passive: false }, calling preventDefault() does nothing and the page scrolls anyway.

factor = zoomIn ? ZOOM_FACTOR : 1 / ZOOM_FACTOR

Zooming in multiplies the range by 0.8 (makes it smaller). Zooming out divides by 0.8 (makes it bigger). This keeps zoom in and out symmetric, so ten zooms in followed by ten zooms out brings you back to exactly where you started.

Centering on the cursor

The new approach maps the cursor pixel to a point in the complex plane, then builds the new window symmetrically around it:

const centerReal = getRelativePoint(mouseX, WIDTH, REAL_SET);
const centerImag = getRelativePoint(mouseY, HEIGHT, IMAGINARY_SET);
Enter fullscreen mode Exit fullscreen mode

getRelativePoint converts a pixel position to a coordinate using a simple formula:

const getRelativePoint = (pixel, length, set) =>
  set.start + (pixel / length) * (set.end - set.start);
Enter fullscreen mode Exit fullscreen mode

ZOOM_FACTOR = 0.8 instead of 0.1

With 0.1, each click zoomed to 20% of the range, which was very aggressive. The precision limit was hit in 16 steps. With 0.8, each scroll step reduces the range by only 20%, so you can zoom about 130 times before hitting the same limit. It also feels much smoother to use.

The Fix, Part 2: The Precision Guard

if (newRealRange < MIN_RANGE || newImagRange < MIN_RANGE) return;
Enter fullscreen mode Exit fullscreen mode

With MIN_RANGE = 1e-12 we stop zooming in when the coordinate window gets too small. At that scale, the numbers don't have enough precision left to render a meaningful image. Instead of turning black, the fractal just stays frozen at the last good zoom level. The scroll event is silently ignored.

How the Renderer Still Works

For context, here is how each column of pixels is computed in the Web Worker. This part is the same as in the previous article:

// worker.ts, runs in a separate thread via Vite's ?worker import

const MAX_ITERATION = 1000;

function mandelbrot(c: { x: number; y: number }): [number, boolean] {
  let z = { x: 0, y: 0 };
  let n = 0;
  let d = 0;
  do {
    const p = {
      x: Math.pow(z.x, 2) - Math.pow(z.y, 2),
      y: 2 * z.x * z.y,
    };
    z = { x: p.x + c.x, y: p.y + c.y };
    d = 0.5 * (Math.pow(z.x, 2) + Math.pow(z.y, 2));
    n += 1;
  } while (d <= 2 && n < MAX_ITERATION);
  return [n, d <= 2];
}
Enter fullscreen mode Exit fullscreen mode

This is the core iteration: z → z² + c. A point c is in the Mandelbrot set if |z| never escapes 2 after MAX_ITERATION steps. Points that do escape get colored by how fast they did it (the value of n).

The main thread sends one message per column and the worker replies with the results:

// columns are dispatched in random order for a cool reveal effect
const launchTasks = () => {
  while (TASKS.length > 0) {
    const [col] = TASKS.splice(Math.floor(Math.random() * TASKS.length), 1);
    worker.postMessage({ col });
  }
};
Enter fullscreen mode Exit fullscreen mode

Current Limitations

Here is an honest list of what this implementation still can't do:

Limitation Why it happens
~130 scroll steps max zoom JavaScript number precision (15-17 digits). You need a different approach to go deeper.
Re-renders the full canvas on every scroll event The worker is restarted on each zoom. Fast scrolling queues many full renders.
No mobile support wheel events don't fire on touch screens. You'd need to handle pinch gestures separately.
Single worker for all columns One worker handles all 800 columns. Multiple workers could be faster.
Fixed MAX_ITERATION = 1000 Deep zoom areas need more iterations to look good, but raising this constant slows everything down.

Future Improvements

1. Arbitrary Precision with decimal.js

To zoom beyond ~130 steps you need more than the standard 64-bit number format. The decimal.js library lets you set how many digits of precision you want:

import Decimal from 'decimal.js';

Decimal.set({ precision: 50 }); // 50 significant digits

const newRange = new Decimal(realRange).mul(factor);
const center  = new Decimal(realSet.start)
  .plus(new Decimal(mouseX).div(WIDTH).mul(new Decimal(realSet.end).minus(realSet.start)));
Enter fullscreen mode Exit fullscreen mode

The downside is that this kind of math is 10 to 100 times slower than normal numbers, so you would need to lower the canvas resolution or the number of iterations to keep things running at a good speed.

2. Perturbation Theory

This is the technique used by professional deep-zoom renderers like Kalles Fraktaler. The idea is to compute one very precise reference point and then calculate all other pixels as small adjustments relative to that point, using regular numbers. This can reach zoom depths of 10^1000 and beyond, with good performance, but it requires a solid math background to implement.

3. Adaptive MAX_ITERATION

Instead of a fixed limit, scale the number of iterations based on how deep the zoom is, so shallow views are fast and deep views show more detail:

const maxIter = Math.floor(100 + zoomLevel * 50);
Enter fullscreen mode Exit fullscreen mode

4. RAF Throttle

The scroll event fires much faster than the renderer can keep up. Using requestAnimationFrame would skip frames that come in too quickly and only render when the browser is ready:

let rafId: number;
canvas.addEventListener('wheel', (e) => {
  e.preventDefault();
  updateCoordinates(e);
  cancelAnimationFrame(rafId);
  rafId = requestAnimationFrame(() => Mandelbrot());
}, { passive: false });
Enter fullscreen mode Exit fullscreen mode

5. Pinch-to-Zoom (Mobile)

Handle touchstart and touchmove with two fingers to calculate a scale factor and apply the same zoom logic.

Summary of Changes

What changed Before After
Interaction click, zoom in only wheel, zoom in and out
Zoom center Approximate click pixel Exact cursor coordinate
Zoom step 20% of range per click 20% of range per scroll tick
Precision guard None, canvas turns black Stops at 1e-12 range
Max useful zooms ~16 ~130
Page scroll behavior Not a concern Blocked with passive: false

Try It Live

You can see the demo running on quijosakaf.com and find the full source on GitHub.

Repository:
github

If you want to experiment, try changing ZOOM_FACTOR between 0.5 (aggressive) and 0.95 (very smooth). The math works the same either way, it's just a personal preference.

Thanks for Reading

If you made it this far, thank you so much. This kind of topic can get complicated fast, and I appreciate you sticking with it.

I want to be honest: this post was written with the help of AI (Claude). Concepts like IEEE 754, catastrophic cancellation, arbitrary precision arithmetic, and perturbation theory were things I did not know about before I started digging into why the zoom was breaking. The AI helped me understand why each thing was happening and gave me the right words to describe it, which made it much easier to explain here.

The demo will keep improving. The improvements listed above (RAF throttling, adaptive iterations, arbitrary precision, pinch-to-zoom) are real next steps I plan to work on. If you have ideas, found a bug, or just want to talk about fractals, drop a comment below.

Top comments (0)