codex/illustrative-food-web.py

illustrative-food-web.py
"""Illustrative feeding routes, not measurements of a named ecosystem.

Tested with Python 3.14.4, NumPy 2.3.5 and Matplotlib 3.10.7.
Write an opaque white same-basename PNG to the working directory.
"""

from pathlib import Path

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch


INK = "#243642"
BLUE = "#246a92"
BROWN = "#8b5939"


def arrow(ax, start, end, *, dashed=False, bend=0):
    ax.add_patch(FancyArrowPatch(
        start, end, arrowstyle="-|>", mutation_scale=18,
        connectionstyle=f"arc3,rad={bend}", linewidth=2,
        linestyle="--" if dashed else "-", color=BROWN if dashed else BLUE,
        zorder=1,
    ))


def box(ax, x, y, name, role, color):
    ax.add_patch(FancyBboxPatch(
        (x - 0.93, y - 0.43), 1.86, 0.86,
        boxstyle="round,pad=0.05", facecolor=color,
        edgecolor=INK, linewidth=1.3, zorder=2,
    ))
    ax.text(x, y + 0.11, name, ha="center", va="center",
            fontsize=12, weight="bold", color=INK)
    ax.text(x, y - 0.18, role, ha="center", va="center",
            fontsize=9.5, color=INK)


def main():
    fig, ax = plt.subplots(figsize=(9.0, 4.8))
    fig.set_facecolor("white")
    ax.set_facecolor("white")
    ax.set(xlim=(0, 10.6), ylim=(0, 5.5))
    ax.axis("off")
    fig.subplots_adjust(left=0.02, right=0.98, top=0.96, bottom=0.04)
    ax.text(5.3, 5.14, "An illustrative aquatic food web",
            ha="center", fontsize=16, weight="bold", color=INK)
    ax.text(5.3, 4.72, "Generic groups and assumed routes • not ecosystem measurements",
            ha="center", fontsize=11, color=INK)

    xs = [1.25, 3.95, 6.65, 9.35]
    names = ["Algae", "Zooplankton", "Small fish", "Large fish"]
    roles = ["primary producers", "primary consumers",
             "predator AND prey", "predators of small fish"]
    colors = ["#e7f2de", "#e5f1fb", "#e5f1fb", "#e5f1fb"]
    for x, name, role, color in zip(xs, names, roles, colors):
        box(ax, x, 3.65, name, role, color)
    for x1, x2 in zip(xs[:-1], xs[1:]):
        arrow(ax, (x1 + 1.0, 3.65), (x2 - 1.0, 3.65))

    box(ax, 3.7, 1.75, "Detritus", "dead organic material", "#f6ecdf")
    box(ax, 7.4, 1.75, "Decomposers", "e.g. bacteria and fungi", "#f6ecdf")
    arrow(ax, (1.25, 3.17), (2.69, 1.9), dashed=True, bend=0.10)
    arrow(ax, (6.65, 3.17), (4.71, 1.9), dashed=True, bend=-0.10)
    arrow(ax, (4.72, 1.75), (6.39, 1.75))
    ax.text(3.7, 2.72, "Selected dead-material transfers",
            ha="center", fontsize=10.5, color=BROWN)

    arrow(ax, (0.45, 0.8), (1.35, 0.8))
    ax.text(1.55, 0.8, "RESOURCE → CONSUMER", va="center",
            fontsize=11, color=BLUE, weight="bold")
    arrow(ax, (6.0, 0.8), (6.9, 0.8), dashed=True)
    ax.text(7.1, 0.8, "dead-material transfer", va="center",
            fontsize=11, color=BROWN)
    ax.text(5.3, 0.24, "Other routes omitted; box sizes and arrow widths carry no quantitative scale",
            ha="center", fontsize=10, color=INK)
    fig.savefig(Path(__file__).with_suffix(".png").name, dpi=130,
                facecolor="white", transparent=False)
    plt.close(fig)
    print("Illustration: 4 feeding positions, 3 chain arrows, 2 dead-material arrows.")


if __name__ == "__main__":
    main()