Mandelbrot Set Fractal Visualization — Pygal

A visualization of the Mandelbrot set, the most iconic fractal in mathematics. Each pixel represents a complex number c on the complex plane, colored by how quickly the iteration z(n+1) = z(n)^2 + c diverges. The boundary between convergent and divergent regions reveals infinitely complex, self-similar structure including the characteristic cardioid and period-2 bulb.

Mandelbrot Set Fractal Visualization rendered with Pygal

Python source (Pygal)

""" anyplot.ai
heatmap-mandelbrot: Mandelbrot Set Fractal Visualization
Library: pygal 3.1.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-30
"""

import base64
import os
import sys
from io import BytesIO
from pathlib import Path


sys.path = [p for p in sys.path if p != str(Path(__file__).parent)]

import numpy as np
from PIL import Image
from pygal.graph.graph import Graph
from pygal.style import Style


# Theme tokens — Imprint palette, theme-adaptive chrome
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Imprint sequential colormap: brand green (#009E73) → blue (#4467A3)
_SEQ_START = (0, 158, 115)  # #009E73
_SEQ_END = (68, 103, 163)  # #4467A3

# Data — Mandelbrot set on the complex plane
x_min, x_max = -2.5, 1.0
y_min, y_max = -1.25, 1.25
max_iter = 200
bailout = 256
grid_w, grid_h = 1400, 1000

real = np.linspace(x_min, x_max, grid_w)
imag = np.linspace(y_max, y_min, grid_h)
c = real[np.newaxis, :] + 1j * imag[:, np.newaxis]

z = np.zeros_like(c)
escape_iter = np.full(c.shape, max_iter, dtype=np.float64)
mask = np.ones(c.shape, dtype=bool)

for i in range(max_iter):
    z[mask] = z[mask] ** 2 + c[mask]
    escaped = mask & (np.abs(z) > bailout)
    log_zn = np.log(np.abs(z[escaped]))
    nu = np.log(log_zn / np.log(bailout)) / np.log(2)
    escape_iter[escaped] = i + 1 - nu
    mask[escaped] = False

exterior = escape_iter < max_iter

# Imprint sequential LUT: #009E73 → #4467A3 (1024 stops)
lut_size = 1024
t_vals = np.linspace(0, 1, lut_size)
lut = np.zeros((lut_size, 3), dtype=np.uint8)
for ch, (s, e) in enumerate(zip(_SEQ_START, _SEQ_END, strict=True)):
    lut[:, ch] = np.round(s + (e - s) * t_vals).astype(np.uint8)

# Log-normalized color mapping (exterior = Imprint seq; interior = black)
cell_colors = np.zeros((*c.shape, 3), dtype=np.uint8)
log_min, log_max = 0.0, 1.0
if np.any(exterior):
    iter_vals = escape_iter[exterior]
    log_vals = np.log(iter_vals + 1)
    log_min, log_max = log_vals.min(), log_vals.max()
    span = log_max - log_min
    normalized = (log_vals - log_min) / span if span > 0 else np.zeros_like(log_vals)
    indices = np.clip((normalized * (lut_size - 1)).astype(int), 0, lut_size - 1)
    cell_colors[exterior] = lut[indices]

# Encode heatmap as PNG data URI for SVG embedding
heatmap_img = Image.fromarray(cell_colors)
buf = BytesIO()
heatmap_img.save(buf, format="PNG")
heatmap_data_uri = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()

# Title — 48 chars < 67 baseline, no scaling needed
title_str = "heatmap-mandelbrot · python · pygal · anyplot.ai"
title_fontsize = round(66 * 67 / len(title_str)) if len(title_str) > 67 else 66


# Module-level function holds all SVG generation logic — keeps class minimal
def _draw_heatmap_overlay(self):
    gw = self.view.width
    gh = self.view.height

    pad_left, pad_top = 200, 80
    pad_right, pad_bottom = 200, 100
    x_span = self._x_range[1] - self._x_range[0]
    y_span = self._y_range[1] - self._y_range[0]

    avail_w = gw - pad_left - pad_right
    avail_h = gh - pad_top - pad_bottom
    if avail_w / avail_h > x_span / y_span:
        plot_h = avail_h
        plot_w = plot_h * x_span / y_span
    else:
        plot_w = avail_w
        plot_h = plot_w * y_span / x_span

    px, py = pad_left, pad_top
    root = self.svg.node(self.nodes["plot"], class_="mandelbrot-heatmap")

    # Embedded heatmap image
    ns = "http://www.w3.org/1999/xlink"
    img = self.svg.node(root, "image", x=px, y=py, width=plot_w, height=plot_h)
    img.attrib["{%s}href" % ns] = self._heatmap_uri
    img.attrib["preserveAspectRatio"] = "none"

    # Plot border
    self.svg.node(
        root, "rect", x=px, y=py, width=plot_w, height=plot_h, style=f"fill:none;stroke:{INK_SOFT};stroke-width:2"
    )

    # Subtitle — mathematical formula in italic serif, sized to match axis titles
    sub = self.svg.node(
        root,
        "text",
        x=px + plot_w / 2,
        y=py - 14,
        style=(
            f"font-size:43px;font-style:italic;font-weight:300;"
            f"font-family:'Georgia',serif;fill:{INK_MUTED};letter-spacing:1px"
        ),
    )
    sub.text = "zₙ₊₁ = zₙ² + c · escape time, smooth coloring"
    sub.attrib["text-anchor"] = "middle"

    # Reference hairline at Im=0 — marks the real axis
    im0_frac = (self._y_range[1] - 0.0) / y_span
    im0_y = py + im0_frac * plot_h
    self.svg.node(
        root,
        "line",
        x1=px,
        y1=im0_y,
        x2=px + plot_w,
        y2=im0_y,
        style=f"stroke:{INK_SOFT};stroke-width:1;stroke-dasharray:6,4;opacity:0.5",
    )
    im0_lbl = self.svg.node(
        root, "text", x=px + 10, y=im0_y - 9, style=f"font-size:25px;font-family:sans-serif;fill:{INK};opacity:0.85"
    )
    im0_lbl.text = "Im = 0"

    # Period-2 bulb label — placed in colored exterior above the bulb (Im ≈ 0.37)
    # so the label uses INK against green and is readable in both themes
    p2_re = -1.0
    ann_im = 0.37  # exterior above the bulb top (~Im 0.25)
    ann_frac_x = (p2_re - self._x_range[0]) / x_span
    ann_frac_y = (self._y_range[1] - ann_im) / y_span
    ann_cx = px + ann_frac_x * plot_w
    ann_cy = py + ann_frac_y * plot_h
    bulb_top_im = 0.26
    bulb_top_frac_y = (self._y_range[1] - bulb_top_im) / y_span
    bulb_top_y = py + bulb_top_frac_y * plot_h
    self.svg.node(
        root,
        "line",
        x1=ann_cx,
        y1=ann_cy + 6,
        x2=ann_cx,
        y2=bulb_top_y - 6,
        style=f"stroke:{INK};stroke-width:1.5;opacity:0.6",
    )
    ann = self.svg.node(
        root, "text", x=ann_cx, y=ann_cy, style=f"font-size:25px;font-family:sans-serif;fill:{INK};opacity:0.85"
    )
    ann.text = "Period-2 bulb"
    ann.attrib["text-anchor"] = "middle"

    # X-axis ticks and labels
    for val in [-2.5, -2.0, -1.5, -1.0, -0.5, 0.0, 0.5, 1.0]:
        frac = (val - self._x_range[0]) / x_span
        tx = px + frac * plot_w
        ty = py + plot_h
        self.svg.node(root, "line", x1=tx, y1=ty, x2=tx, y2=ty + 14, style=f"stroke:{INK_SOFT};stroke-width:2")
        lbl = self.svg.node(
            root, "text", x=tx, y=ty + 52, style=f"font-size:34px;font-family:sans-serif;fill:{INK_SOFT}"
        )
        lbl.text = f"{val:.1f}"
        lbl.attrib["text-anchor"] = "middle"

    # X-axis title
    xl = self.svg.node(
        root,
        "text",
        x=px + plot_w / 2,
        y=py + plot_h + 90,
        style=f"font-size:44px;font-weight:600;font-family:sans-serif;fill:{INK}",
    )
    xl.text = "Real Axis (Re)"
    xl.attrib["text-anchor"] = "middle"

    # Y-axis ticks and labels
    for val in [-1.0, -0.5, 0.0, 0.5, 1.0]:
        frac = (self._y_range[1] - val) / y_span
        ty = py + frac * plot_h
        self.svg.node(root, "line", x1=px - 14, y1=ty, x2=px, y2=ty, style=f"stroke:{INK_SOFT};stroke-width:2")
        label = f"{val:+.1f}i" if val != 0 else "0.0i"
        lbl = self.svg.node(
            root, "text", x=px - 24, y=ty + 12, style=f"font-size:34px;font-family:sans-serif;fill:{INK_SOFT}"
        )
        lbl.text = label
        lbl.attrib["text-anchor"] = "end"

    # Y-axis title (rotated)
    ylx = px - 170
    yly = py + plot_h / 2
    yl = self.svg.node(
        root, "text", x=ylx, y=yly, style=f"font-size:44px;font-weight:600;font-family:sans-serif;fill:{INK}"
    )
    yl.text = "Imaginary Axis (Im)"
    yl.attrib["text-anchor"] = "middle"
    yl.attrib["transform"] = f"rotate(-90, {ylx}, {yly})"

    # Colorbar gradient
    cb_x = px + plot_w + 30
    cb_w = 40
    cb_top = py + 40
    cb_h = plot_h - 80
    n_seg = 100

    for s in range(n_seg):
        t = 1.0 - s / (n_seg - 1)
        ci = min(int(t * (self._lut_sz - 1)), self._lut_sz - 1)
        r, g, b = self._colorbar_lut[ci]
        sy = cb_top + s * cb_h / n_seg
        self.svg.node(
            root,
            "rect",
            x=cb_x,
            y=sy,
            width=cb_w,
            height=cb_h / n_seg + 1,
            style=f"fill:#{r:02x}{g:02x}{b:02x};stroke:none",
        )

    self.svg.node(
        root, "rect", x=cb_x, y=cb_top, width=cb_w, height=cb_h, style=f"fill:none;stroke:{INK_SOFT};stroke-width:1.5"
    )

    # Colorbar tick labels
    log_span = self._log_range[1] - self._log_range[0]
    for iter_val in [1, 5, 10, 25, 50, 100, 200]:
        log_val = np.log(iter_val + 1)
        if log_val < self._log_range[0] or log_val > self._log_range[1]:
            continue
        t_c = (log_val - self._log_range[0]) / log_span if log_span > 0 else 0
        frac = 1.0 - t_c
        ty = cb_top + frac * cb_h
        self.svg.node(
            root, "line", x1=cb_x + cb_w, y1=ty, x2=cb_x + cb_w + 8, y2=ty, style=f"stroke:{INK_SOFT};stroke-width:1.5"
        )
        lbl = self.svg.node(
            root, "text", x=cb_x + cb_w + 14, y=ty + 10, style=f"font-size:28px;font-family:sans-serif;fill:{INK_SOFT}"
        )
        lbl.text = str(iter_val)

    # Colorbar title
    cbt = self.svg.node(
        root,
        "text",
        x=cb_x - 5,
        y=cb_top - 18,
        style=f"font-size:32px;font-weight:600;font-family:sans-serif;fill:{INK}",
    )
    cbt.text = "Iterations"
    cbt.attrib["text-anchor"] = "start"

    # In-set legend
    lg_y = cb_top + cb_h + 38
    self.svg.node(
        root, "rect", x=cb_x, y=lg_y, width=26, height=26, style=f"fill:#000000;stroke:{INK_SOFT};stroke-width:1"
    )
    lg = self.svg.node(
        root, "text", x=cb_x + 38, y=lg_y + 20, style=f"font-size:26px;font-family:sans-serif;fill:{INK_SOFT}"
    )
    lg.text = "In set"


# Minimal pygal Graph subclass — routing stubs only, logic lives in _draw_heatmap_overlay
class _HeatmapGraph(Graph):
    _adapters = []
    _compute = _compute_x_labels = _compute_y_labels = _compute_x_labels_major = _compute_y_labels_major = lambda self: (
        None
    )
    _plot = _draw_heatmap_overlay


# Pygal Style — theme-adaptive Imprint palette
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=("#009E73",),
    title_font_size=title_fontsize,
    title_font_family="sans-serif",
    label_font_size=44,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
)

# Create chart using pygal's rendering pipeline
chart = _HeatmapGraph(
    width=2400,
    height=2400,
    style=custom_style,
    title=title_str,
    show_legend=False,
    show_x_guides=False,
    show_y_guides=False,
    print_values=False,
    margin=30,
    spacing=10,
)

# Bind heatmap data to chart instance (avoids __init__ override)
chart._heatmap_uri = heatmap_data_uri
chart._x_range = (x_min, x_max)
chart._y_range = (y_min, y_max)
chart._colorbar_lut = lut
chart._log_range = (log_min, log_max)
chart._lut_sz = lut_size

chart.add("In set", [1])

# Save PNG and interactive HTML (both theme-suffixed)
chart.render_to_png(f"plot-{THEME}.png")

svg_content = chart.render().decode("utf-8")
html_content = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>heatmap-mandelbrot · python · pygal · anyplot.ai</title>
    <style>
        body {{ margin: 0; display: flex; justify-content: center; align-items: center;
               min-height: 100vh; background: {PAGE_BG}; }}
        .chart {{ max-width: 100%; height: auto; }}
    </style>
</head>
<body>
    <figure class="chart">
        {svg_content}
    </figure>
</body>
</html>
"""

with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
    f.write(html_content)

Part of Mandelbrot Set Fractal Visualization on anyplot.ai.

Other implementations