"""Draw the set as a silhouette, with four values of c marked in and out.

The escape test of mandelbrot.py on a coarse grid, turned into one filled
outline rather than a coloured image. The result is a drawing rather than a
photograph, so it stays a few tens of kilobytes and follows the page's
theme, which a raster cannot do.

Writes membership.svg beside this file.
"""
import numpy as np
import matplotlib
matplotlib.use("Agg")
matplotlib.rcParams["svg.fonttype"] = "path"
import matplotlib.pyplot as plt

TEAL, INDIGO, ROSE, AMBER = "#00706B", "#3A49A6", "#A93356", "#B36A1E"
INK, MUTED, GRID, FINE = "#0D1620", "#5A6874", "#D3DBE3", "#E1E7ED"

RE, IM = (-2.35, 1.20), (-1.28, 1.28)
W, H = 560, 410
NMAX = 120

# Four values of c, chosen so that two are plainly in, one is plainly out,
# and one sits close enough to the edge to show that nearness decides
# nothing. Each is checked by the same loop that draws the silhouette.
SAMPLES = [(-1 + 0j, "c = -1", TEAL, (-0.02, 0.16), "right", "bottom"),
           (0 + 1j, "c = i", TEAL, (0.10, 0.06), "left", "bottom"),
           (0.4 + 0.2j, "c = 0.4 + 0.2i", AMBER, (0.06, 0.05), "left", "bottom"),
           (1 + 0j, "c = 1", AMBER, (0.0, -0.10), "center", "top")]


def escapes(c, nmax=NMAX):
    """True when the orbit of zero leaves the disc of radius 2."""
    z = np.zeros_like(np.asarray(c, dtype=complex))
    for _ in range(nmax):
        z = z * z + c
        if np.all(np.abs(z) > 2.0):
            return True
    return bool(np.all(np.abs(z) > 2.0))


# What each drawing colour becomes in the file. The page carries these
# drawings rather than linking them, so they inherit the reader's theme
# instead of answering the operating system. An img element could not:
# it is a separate document and cannot read --ink from the page.
PROPERTY = {TEAL: "--teal", INDIGO: "--indigo", ROSE: "--rose",
            AMBER: "--amber", INK: "--ink", MUTED: "--muted",
            GRID: "--line", FINE: "--sunk"}


def save(fig, name):
    """Write the figure, then make it the page's own drawing.

    Three passes over what matplotlib produced. Every colour becomes a
    custom property with the literal kept as a fallback. Every id is given
    the file's stem, because several of these are inlined into one page and
    matplotlib numbers them from one in each. And six decimal places become
    two, which is 0.005 of a point, well under a pixel at any size the page
    uses. A number whose whole part is zero is left alone: those are the
    glyph scale factors, where rounding would resize the text.
    """
    import re

    fig.tight_layout()
    # transparent, so the page shows through. Without it matplotlib
    # paints an opaque white rectangle behind everything, and the
    # figure then reads as a light card whatever the theme does to
    # the ink drawn on it.
    fig.savefig(name, transparent=True)
    text = open(name, encoding="utf-8").read()

    stem = re.sub(r"[^A-Za-z0-9]+", "-", name.rsplit(".", 1)[0]).strip("-")
    text = re.sub(r'(\sid=")([^"]+)"',
                  lambda m: f'{m.group(1)}{stem}-{m.group(2)}"', text)
    text = re.sub(r'((?:xlink:)?href="#)([^"]+)"',
                  lambda m: f'{m.group(1)}{stem}-{m.group(2)}"', text)
    text = re.sub(r'(url\(#)([^)]+)\)',
                  lambda m: f'{m.group(1)}{stem}-{m.group(2)})', text)

    for literal, prop in PROPERTY.items():
        text = re.sub(re.escape(literal), f"var({prop}, {literal.lower()})",
                      text, flags=re.IGNORECASE)

    text = re.sub(r"(\d+)\.(\d{3,})",
                  lambda m: (m.group(0) if m.group(1) == "0"
                             else f"{float(m.group(0)):.2f}".rstrip("0").rstrip(".")),
                  text)

    with open(name, "w", encoding="utf-8") as f:
        f.write(text)
    print(f"{name} written")

re = np.linspace(*RE, W)
im = np.linspace(*IM, H)
c = re[None, :] + 1j * im[:, None]
z = np.zeros_like(c)
alive = np.ones(c.shape, dtype=bool)
for _ in range(NMAX):
    z[alive] = z[alive] ** 2 + c[alive]
    alive &= np.abs(z) <= 2.0

fig, ax = plt.subplots(figsize=(7.4, 5.0))
for x in np.arange(-2.5, 1.01, 0.1):
    ax.axvline(x, lw=0.5, color=FINE, zorder=0)
for y in np.arange(-1.5, 1.51, 0.1):
    ax.axhline(y, lw=0.5, color=FINE, zorder=0)
for x in np.arange(-2.5, 1.01, 0.5):
    ax.axvline(x, lw=0.8, color=GRID, zorder=0)
for y in np.arange(-1.5, 1.51, 0.5):
    ax.axhline(y, lw=0.8, color=GRID, zorder=0)

# One filled region rather than an image, so the drawing scales and takes
# the page's colours. The boundary is infinitely detailed, and this grid
# resolves it only as far as 900 by 700 samples allow.
ax.contourf(re, im, alive.astype(float), levels=[0.5, 1.5], colors=[MUTED],
            alpha=0.65, zorder=1)

ax.axhline(0, lw=1.2, color=INK, zorder=3)
ax.axvline(0, lw=1.2, color=INK, zorder=3)
for x in (-2, -1, 0.5):
    ax.plot([x, x], [-0.045, 0.045], lw=1.1, color=INK, zorder=4)
    ax.annotate(f"{x:g}", (x, -0.08), ha="center", va="top", fontsize=10, color=INK)
for y, lab in ((1, "i"), (-1, "-i")):
    ax.plot([-0.035, 0.035], [y, y], lw=1.1, color=INK, zorder=4)
    ax.annotate(lab, (-0.06, y), ha="right", va="center", fontsize=10, color=INK)

for value, lab, colour, (dx, dy), ha, va in SAMPLES:
    ax.plot(value.real, value.imag, "o", ms=8, color=colour, zorder=5)
    ax.annotate(lab, (value.real + dx, value.imag + dy), ha=ha, va=va,
                fontsize=11, color=colour)
    print(f"  {lab:18} escapes: {escapes(value)}")

ax.annotate("in the set, the orbit of zero stays bounded", (-2.32, -1.06),
            ha="left", va="center", fontsize=10, color=TEAL)
ax.annotate("outside it, the orbit runs away", (-2.32, -1.20), ha="left",
            va="center", fontsize=10, color=AMBER)

ax.set_xlim(*RE)
ax.set_ylim(*IM)
ax.set_aspect("equal")
ax.set_xticks([])
ax.set_yticks([])
for s in ax.spines.values():
    s.set_visible(False)
save(fig, "membership.svg")
