Click on the green image above to zoom in
Detailed explanation below the code.
interface Point {
readonly x: number;
readonly y: number;
}
interface ViewBox {
readonly xMin: number;
readonly xMax: number;
readonly yMin: number;
readonly yMax: number;
}
interface AffineMap {
readonly a: number;
readonly b: number;
readonly c: number;
readonly d: number;
readonly e: number;
readonly f: number;
/** Upper bound of this map's slice of the [0, 1) draw. */
readonly upTo: number;
}
interface Layout {
readonly scale: number;
readonly offsetX: number;
readonly offsetY: number;
}
(() => {
'use strict';
const MAPS: readonly AffineMap[] = [
{ a: 0, b: 0, c: 0, d: 0.16, e: 0, f: 0, upTo: 0.01 }, // stem
{ a: 0.85, b: 0.04, c: -0.04, d: 0.85, e: 0, f: 1.6, upTo: 0.86 }, // successively smaller leaflets
{ a: 0.2, b: -0.26, c: 0.23, d: 0.22, e: 0, f: 1.6, upTo: 0.93 }, // largest left leaflet
{ a: -0.15, b: 0.28, c: 0.26, d: 0.24, e: 0, f: 0.44, upTo: 1 }, // largest right leaflet
];
function pickMap(): AffineMap {
const r = Math.random();
for (const m of MAPS) if (r < m.upTo) return m;
return MAPS[MAPS.length - 1];
}
function applyMap(p: Point, m: AffineMap): Point {
return {
x: m.a * p.x + m.b * p.y + m.e,
y: m.c * p.x + m.d * p.y + m.f,
};
}
/**
* Pure chaos-game engine. No DOM dependency by design, so the maths
* can be unit-tested in isolation from the rendering/zoom code below.
*/
class FernEngine {
static readonly NATURAL_BOUNDS: ViewBox = {
xMin: -2.35, xMax: 2.8, yMin: -0.2, yMax: 10.2,
};
private point: Point = { x: 0, y: 0 };
constructor(warmupSteps = 60) {
for (let i = 0; i < warmupSteps; i++) {
this.point = applyMap(this.point, pickMap());
}
}
next(): Point {
this.point = applyMap(this.point, pickMap());
return this.point;
}
}
/** Dark soil -> moss -> fresh frond -> sunlit tip. */
const PALETTE: ReadonlyArray = [
[0, [16, 43, 27]],
[0.45, [45, 130, 70]],
[0.78, [123, 206, 100]],
[1, [228, 247, 196]],
];
function sampleGradient(t: number): readonly [number, number, number] {
for (let i = 1; i < PALETTE.length; i++) {
const [t0, c0] = PALETTE[i - 1];
const [t1, c1] = PALETTE[i];
if (t <= t1) {
const lt = (t - t0) / (t1 - t0 || 1);
return [
c0[0] + (c1[0] - c0[0]) * lt,
c0[1] + (c1[1] - c0[1]) * lt,
c0[2] + (c1[2] - c0[2]) * lt,
];
}
}
return PALETTE[PALETTE.length - 1][1];
}
function nextFrame(): Promise {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
class FernRenderer {
private static readonly ZOOM_FACTOR = 1.9;
private static readonly MAX_ZOOM = 200_000;
private static readonly MIN_ITERATIONS = 2_000_000;
private static readonly MAX_ITERATIONS = 12_000_000;
private static readonly ITERATIONS_PER_AREA_RATIO = 150_000;
private static readonly NATURAL_AREA =
(FernEngine.NATURAL_BOUNDS.xMax - FernEngine.NATURAL_BOUNDS.xMin) *
(FernEngine.NATURAL_BOUNDS.yMax - FernEngine.NATURAL_BOUNDS.yMin);
private readonly engine = new FernEngine();
private readonly ctx: CanvasRenderingContext2D;
private view: ViewBox = FernEngine.NATURAL_BOUNDS;
private zoom = 1;
private busy = false;
constructor(
private readonly canvas: HTMLCanvasElement,
private readonly zoomLabel: HTMLElement,
private readonly resetButton: HTMLButtonElement,
private readonly hint: HTMLElement
) {
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('2D canvas context is not available.');
this.ctx = ctx;
this.fitCanvas();
window.addEventListener('resize', () => this.handleResize());
canvas.addEventListener('click', (event) => void this.handleClick(event));
resetButton.addEventListener('click', () => this.reset());
this.render();
}
private fitCanvas(): void {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = this.canvas.getBoundingClientRect();
this.canvas.width = Math.max(1, Math.round(rect.width * dpr));
this.canvas.height = Math.max(1, Math.round(rect.height * dpr));
}
private handleResize(): void {
this.fitCanvas();
this.render();
}
private layout(): Layout {
const w = this.canvas.width;
const h = this.canvas.height;
const viewW = this.view.xMax - this.view.xMin;
const viewH = this.view.yMax - this.view.yMin;
const scale = Math.min(w / viewW, h / viewH);
return {
scale,
offsetX: (w - viewW * scale) / 2,
offsetY: (h - viewH * scale) / 2,
};
}
private toCanvas(p: Point, layout: Layout): Point {
return {
x: layout.offsetX + (p.x - this.view.xMin) * layout.scale,
y: layout.offsetY + (this.view.yMax - p.y) * layout.scale, // flip: fractal y grows upward
};
}
private toFractal(x: number, y: number, layout: Layout): Point {
return {
x: this.view.xMin + (x - layout.offsetX) / layout.scale,
y: this.view.yMax - (y - layout.offsetY) / layout.scale,
};
}
private iterationBudget(): number {
const viewArea = (this.view.xMax - this.view.xMin) * (this.view.yMax - this.view.yMin);
const areaRatio = FernRenderer.NATURAL_AREA / viewArea;
const scaled = FernRenderer.ITERATIONS_PER_AREA_RATIO * areaRatio;
return Math.min(FernRenderer.MAX_ITERATIONS, Math.max(FernRenderer.MIN_ITERATIONS, Math.round(scaled)));
}
private render(): void {
const w = this.canvas.width;
const h = this.canvas.height;
if (w === 0 || h === 0) return;
const layout = this.layout();
const counts = new Float32Array(w * h);
const iterations = this.iterationBudget();
let maxCount = 1;
for (let i = 0; i < iterations; i++) {
const p = this.engine.next();
if (p.x < this.view.xMin || p.x > this.view.xMax || p.y < this.view.yMin || p.y > this.view.yMax) {
continue;
}
const c = this.toCanvas(p, layout);
const ix = c.x | 0;
const iy = c.y | 0;
if (ix < 0 || ix >= w || iy < 0 || iy >= h) continue;
const idx = iy * w + ix;
const v = counts[idx] + 1;
counts[idx] = v;
if (v > maxCount) maxCount = v;
}
this.paint(counts, w, h, maxCount);
}
private paint(counts: Float32Array, w: number, h: number, maxCount: number): void {
const image = this.ctx.createImageData(w, h);
const data = image.data;
const bg32 = new Uint32Array(data.buffer);
const bgPixel = (255 << 24) | (10 << 16) | (12 << 8) | 7; // RGBA -> little-endian ABGR packing
bg32.fill(bgPixel);
const whitePoint = Math.log1p(Math.max(1, maxCount * 0.55));
for (let i = 0; i < counts.length; i++) {
const count = counts[i];
if (count <= 0) continue;
const t = Math.min(1, Math.log1p(count) / whitePoint);
const [r, g, b] = sampleGradient(t);
const idx = i * 4;
data[idx] = r;
data[idx + 1] = g;
data[idx + 2] = b;
data[idx + 3] = 255;
}
this.ctx.putImageData(image, 0, 0);
}
private async handleClick(event: MouseEvent): Promise {
if (this.busy || this.zoom >= FernRenderer.MAX_ZOOM) return;
const rect = this.canvas.getBoundingClientRect();
const pxPerCssPx = this.canvas.width / rect.width;
const cx = (event.clientX - rect.left) * pxPerCssPx;
const cy = (event.clientY - rect.top) * pxPerCssPx;
const layout = this.layout();
const target = this.toFractal(cx, cy, layout);
const newW = (this.view.xMax - this.view.xMin) / FernRenderer.ZOOM_FACTOR;
const newH = (this.view.yMax - this.view.yMin) / FernRenderer.ZOOM_FACTOR;
this.view = {
xMin: target.x - newW / 2,
xMax: target.x + newW / 2,
yMin: target.y - newH / 2,
yMax: target.y + newH / 2,
};
this.zoom *= FernRenderer.ZOOM_FACTOR;
this.busy = true;
this.canvas.classList.add('is-working');
this.updateLabel();
this.hint.textContent = 'click to keep zooming';
await nextFrame();
this.render();
this.canvas.classList.remove('is-working');
this.busy = false;
}
private reset(): void {
this.view = FernEngine.NATURAL_BOUNDS;
this.zoom = 1;
this.updateLabel();
this.hint.textContent = 'click the fern to zoom in';
this.render();
}
private updateLabel(): void {
const digits = this.zoom < 10 ? 1 : 0;
this.zoomLabel.textContent = `${this.zoom.toFixed(digits)}×`;
this.resetButton.disabled = this.zoom === 1;
}
}
function bootstrap(): void {
const canvas = document.getElementById('fern-canvas');
const zoomLabel = document.getElementById('zoom-label');
const resetButton = document.getElementById('reset-button');
const hint = document.getElementById('hint');
if (
!(canvas instanceof HTMLCanvasElement) ||
!zoomLabel ||
!(resetButton instanceof HTMLButtonElement) ||
!hint
) {
return;
}
new FernRenderer(canvas, zoomLabel, resetButton, hint);
}
document.addEventListener('DOMContentLoaded', bootstrap);
})();
The fern is not drawn by tracing an outline. It is the attractor of an Iterated Function System (IFS): a finite set of contraction mappings whose unique fixed set, by Banach's theorem, is the image you see.
Each mapping is an affine transform — a linear map plus a translation:
Rather than computing the attractor analytically, the code uses the chaos game: start at an arbitrary point, pick one of the four maps at random, apply it, plot the result, repeat. Banach's theorem guarantees convergence — every trajectory ends up sampling the attractor's measure, regardless of where it starts.
The chaos game doesn't prove the attractor exists — that's Banach. It just samples it efficiently. Any starting point converges because every map is a contraction (|det| < 1 for all four maps).
These are Barnsley's original coefficients from Fractals Everywhere (1988). Each map produces a different part of the plant:
| Map | a | b | c | d | e | f | P | Role |
|---|---|---|---|---|---|---|---|---|
| f₁ | 0 | 0 | 0 | 0.16 | 0 | 0 | 0.01 | Stem |
| f₂ | 0.85 | 0.04 | −0.04 | 0.85 | 0 | 1.6 | 0.85 | Successively smaller leaflets (the "recursion") |
| f₃ | 0.2 | −0.26 | 0.23 | 0.22 | 0 | 1.6 | 0.07 | Largest left leaflet |
| f₄ | −0.15 | 0.28 | 0.26 | 0.24 | 0 | 0.44 | 0.07 | Largest right leaflet |
f₂ has probability 0.85 because it's the map that subdivides leaflets into smaller copies of themselves. f₁ has probability 0.01 — it collapses everything onto the y-axis, which is what keeps the stem thin. The probabilities are proportional to each map's area scaling factor (|det|).
Map selection uses a sorted cumulative-probability table and a single random draw — O(1) per iteration:
function pickMap(): AffineMap {
const r = Math.random();
for (const m of MAPS) if (r < m.upTo) return m;
return MAPS[MAPS.length - 1];
}
The upTo field is the right boundary of each map's slice: 0.01, 0.86, 0.93, 1.00. A uniform draw falls into exactly one slice.
The IFS fixed-point theorem says the attractor satisfies:
Substituting f₂ or f₃ into this equation shows that each frond is a contracted, rotated copy of the whole fern. Zooming into a frond doesn't just magnify pixels. The code re-runs the chaos game on the new view box from scratch, and that sub-region independently satisfies the same self-similarity equation. A new fern resolves out of the noise on every zoom level.
This is also why the engine doesn't reset between zooms. The Markov chain is already at stationarity — its stationary distribution is the attractor measure. Points generated after a zoom that fall outside the new view box are discarded, not re-seeded.
FernEngine is intentionally stateless with respect to the DOM. It holds one Point and advances it each call to next(). Isolating the maths from rendering lets the iteration budget and rendering code change independently.
class FernEngine {
private point: Point = { x: 0, y: 0 };
constructor(warmupSteps = 60) {
for (let i = 0; i < warmupSteps; i++) {
this.point = applyMap(this.point, pickMap());
}
}
next(): Point {
this.point = applyMap(this.point, pickMap());
return this.point;
}
}
The constructor runs 60 warmup steps to move off the origin before the first render. The origin is a fixed point of f₁ (it maps to itself under that stem map), so without warmup the first handful of plotted points cluster at (0, 0) and create a bright artefact at the base of the stem.
60 steps is conservative. In practice 20–30 is sufficient for f₂'s dominant eigenvalue (≈ 0.85) to shrink the initial transient below one pixel. 60 is used to be safe across all four maps including f₄, which has a larger off-diagonal in its Jacobian.
The fractal lives in a natural coordinate space where x ∈ [−2.35, 2.8] and y ∈ [−0.2, 10.2]. Canvas pixel space has y pointing down. Two functions translate between them:
// fractal → canvas pixel
toCanvas(p: Point, layout: Layout): Point {
return {
x: layout.offsetX + (p.x - view.xMin) * layout.scale,
y: layout.offsetY + (view.yMax - p.y) * layout.scale,
};
}
// canvas pixel → fractal (used on click)
toFractal(x: number, y: number, layout: Layout): Point {
return {
x: view.xMin + (x - layout.offsetX) / layout.scale,
y: view.yMax - (y - layout.offsetY) / layout.scale,
};
}
The layout.scale is min(canvasW / viewW, canvasH / viewH), which letter-boxes the fractal into the canvas aspect ratio without distortion. offsetX and offsetY centre it within the remaining space.
Canvas width and height are set in physical pixels, accounting for the device pixel ratio (DPR), capped at 2× to avoid a 9M-element Float32Array on 3× retina displays:
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(rect.width * dpr);
canvas.height = Math.round(rect.height * dpr);
Click coordinates arrive as CSS pixels. They are converted to physical canvas pixels before being passed to toFractal:
const pxPerCssPx = canvas.width / rect.width;
const cx = (event.clientX - rect.left) * pxPerCssPx;
const cy = (event.clientY - rect.top) * pxPerCssPx;
When zoomed in, most chaos-game points fall outside the view box and are discarded. The in-view hit rate drops proportionally to the view area. Verified empirically:
| Zoom level (approx.) | Area ratio | Hit rate (2M iters) | Visible points |
|---|---|---|---|
| 1× | 1 | ~56% | ~1,120,000 |
| 3.6× | 13 | ~5.6% | ~112,000 |
| 25× | 613 | ~0.28% | ~5,600 |
| 89× | 7,990 | ~0.006% | ~120 |
The budget scales the iteration count by the area ratio to keep visible point density roughly constant:
private iterationBudget(): number {
const viewArea = (view.xMax - view.xMin) * (view.yMax - view.yMin);
const areaRatio = NATURAL_AREA / viewArea;
const scaled = ITERATIONS_PER_AREA_RATIO * areaRatio; // 150_000
return Math.min(MAX_ITERATIONS, Math.max(MIN_ITERATIONS, Math.round(scaled)));
}
Constants: MIN_ITERATIONS = 2_000_000, MAX_ITERATIONS = 12_000_000. The floor prevents the initial view from being under-sampled on fast machines; the ceiling prevents multi-second pauses at extreme zoom. The render runs synchronously on the main thread — a requestAnimationFrame gate before the call lets the browser commit any pending DOM updates (the zoom label) before the CPU-heavy loop begins.
At zoom ratios beyond ~10,000× the budget hits its 12M ceiling. Visible point density falls off and the image gets sparse. Moving the iteration loop to a Worker would remove the latency cap and allow larger budgets without blocking the main thread.
Each iteration increments a Float32Array counter at the target pixel. The stem and main vein have orders-of-magnitude higher hit rates than the frond tips. Mapping raw counts linearly to brightness bleaches the stem to white and renders the tips invisible.
The fix is logarithmic normalization. For each pixel:
The 0.55 white-point multiplier clips the top 45% of the density range to full brightness, which effectively stretches the palette across the midtones where most of the structure lives. Without it the gradient spends most of its range on the handful of pixels at the absolute maximum and the rest looks flat.
const whitePoint = Math.log1p(Math.max(1, maxCount * 0.55));
for (let i = 0; i < counts.length; i++) {
const count = counts[i];
if (count <= 0) continue;
const t = Math.min(1, Math.log1p(count) / whitePoint);
const [r, g, b] = sampleGradient(t);
/* write to ImageData ... */
}
Math.log1p(x) is used instead of Math.log(1 + x) for numerical accuracy near zero (counts of 1 → log1p(1) = 0.693, correctly non-zero).
Background fill. The raw ImageData buffer is aliased as a Uint32Array and flood-filled with a single 32-bit RGBA word before the per-pixel loop. This is faster than iterating four bytes at a time:
const bg32 = new Uint32Array(data.buffer);
// 0x070c0aff in RGBA → little-endian 0xff0a0c07 in memory
const bgPixel = (255 << 24) | (10 << 16) | (12 << 8) | 7;
bg32.fill(bgPixel);
The bit-packing order is ABGR (little-endian) not RGBA because the Uint32Array view reverses byte order on x86. Only pixels with count > 0 are then written over the background, which avoids touching the vast majority of pixels twice.
The palette is a piecewise-linear gradient with four stops:
| t | RGB | Description |
|---|---|---|
| 0.00 | (16, 43, 27) | Dark soil — background edge |
| 0.45 | (45, 130, 70) | Moss green — main vein density |
| 0.78 | (123, 206, 100) | Fresh frond — leaflet edges |
| 1.00 | (228, 247, 196) | Sunlit tip — highest density |
sampleGradient walks the stop table and linearly interpolates the RGB components between the surrounding pair:
function sampleGradient(t: number): readonly [number, number, number] {
for (let i = 1; i < PALETTE.length; i++) {
const [t0, c0] = PALETTE[i - 1];
const [t1, c1] = PALETTE[i];
if (t <= t1) {
const lt = (t - t0) / (t1 - t0 || 1); // local t in this segment
return [
c0[0] + (c1[0] - c0[0]) * lt,
c0[1] + (c1[1] - c0[1]) * lt,
c0[2] + (c1[2] - c0[2]) * lt,
];
}
}
return PALETTE[PALETTE.length - 1][1];
}
The interpolated values are written directly into the Uint8ClampedArray as floats. The clamped array truncates on assignment; no explicit rounding is needed.
Each click shrinks the current view box by a factor of 1.9, centered on the clicked fractal coordinate:
const newW = (view.xMax - view.xMin) / ZOOM_FACTOR; // 1.9
const newH = (view.yMax - view.yMin) / ZOOM_FACTOR;
view = {
xMin: target.x - newW / 2,
xMax: target.x + newW / 2,
yMin: target.y - newH / 2,
yMax: target.y + newH / 2,
};
1.9× per click was chosen so that a few clicks land you comfortably inside a recognizable frond without feeling like the zoom is skipping. At 2× the jumps feel abrupt; at 1.5× reaching deep zoom levels requires too many clicks.
The hard ceiling is MAX_ZOOM = 200_000 — roughly ten clicks at 1.9× per click. Beyond that, floating-point precision (53-bit mantissa ≈ 15 decimal digits) starts introducing jitter into the coordinate transforms at the scale of single pixels. Rather than add compensating arithmetic, the code simply stops accepting clicks.
A busy flag serializes clicks. If a render is in progress when the next click arrives it is dropped rather than queued. This avoids building up a backlog of stale zoom targets.