Complex Plane Visualization (Argand Diagram) — Altair

An Argand diagram plots complex numbers as points in the complex plane, with the real part on the x-axis and the imaginary part on the y-axis. Vectors from the origin to each point illustrate magnitude and phase angle, while a unit circle provides a geometric reference. This visualization is foundational for complex analysis, signal processing, and understanding operations like addition, multiplication, and roots of unity geometrically.

Complex Plane Visualization (Argand Diagram) rendered with Altair

Python source (Altair)

""" anyplot.ai
scatter-complex-plane: Complex Plane Visualization (Argand Diagram)
Library: altair 6.1.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-06-02
"""

import importlib
import os
import sys

import numpy as np
import pandas as pd
from PIL import Image


# Drop script directory from sys.path so `altair` resolves the package, not this file
sys.path[:] = [p for p in sys.path if os.path.abspath(p or ".") != os.path.dirname(os.path.abspath(__file__))]
alt = importlib.import_module("altair")

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

# Imprint categorical palette — canonical order, theme-independent
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"]

# Data
np.random.seed(42)

# 3rd roots of unity: e^(2πik/3) for k = 0, 1, 2
n_roots = 3
roots_of_unity = [np.exp(2j * np.pi * k / n_roots) for k in range(n_roots)]

# Arbitrary complex numbers across all four quadrants
arbitrary_points = [2.5 + 1.5j, -1.8 + 2.2j, 1.0 - 2.0j, -0.5 - 1.5j, 2.0 + 0.5j]

# Complex rotation: multiply z by e^(iπ/4) — rotation by π/4 radians
z_original = 1.5 + 0.8j
z_rotated = z_original * np.exp(1j * np.pi / 4)

# Build all points with rectangular form labels
all_points = []
point_sets = [
    (roots_of_unity, [f"ω{k}" for k in range(n_roots)], "Roots of Unity"),
    (arbitrary_points, [f"z{chr(0x2081 + k)}" for k in range(5)], "Arbitrary"),
    ([z_original, z_rotated], ["z", "z·e^(iπ/4)"], "Transformation"),
]
for pts, labels, cat in point_sets:
    for lbl, z in zip(labels, pts, strict=True):
        r, i = round(z.real, 2), round(z.imag, 2)
        sign = "+" if i >= 0 else ""
        all_points.append(
            {"real": z.real, "imaginary": z.imag, "label": lbl, "rect_form": f"{r}{sign}{i}i", "category": cat}
        )

df = pd.DataFrame(all_points)
df["annotation"] = df["label"] + " = " + df["rect_form"]

# Label offsets: push labels away from origin by quadrant
# with per-point tuning to separate Q1 cluster (ω0, z, z₅)
offsets = {"dx": [], "dy": [], "align": []}
for _, row in df.iterrows():
    rx, iy = row["real"], row["imaginary"]
    dx = 0.18 if rx >= 0 else -0.18
    dy = 0.25 if iy >= 0 else -0.25
    align = "left" if rx >= 0 else "right"
    # ω0 = (1, 0) lies ON the x-axis — push label well above it to avoid the axis line
    if row["label"] == "ω0":
        dx = 0.05
        dy = 0.48
        align = "center"
    # z₁ (2.5+1.5i) is near the top-right legend — place below-left, right-aligned, to avoid both
    if row["label"] == "z₁":
        dx = -0.20
        dy = -0.30
        align = "right"
    # z₅ (2.0+0.5i) sits close to z (1.5+0.8i) in Q1 — push below x-axis entirely
    if row["label"] == "z₅":
        dx = 0.15
        dy = -0.60
        align = "left"
    # z·e^(iπ/4) — push further up-right to clear the point marker
    if row["label"] == "z·e^(iπ/4)":
        dy = 0.35
        dx = 0.24
    offsets["dx"].append(rx + dx)
    offsets["dy"].append(iy + dy)
    offsets["align"].append(align)

df["label_x"] = offsets["dx"]
df["label_y"] = offsets["dy"]
df["label_align"] = offsets["align"]

# Unit circle parametric data (reference geometry)
theta = np.linspace(0, 2 * np.pi, 200)
circle_df = pd.DataFrame({"x": np.cos(theta), "y": np.sin(theta), "order": range(len(theta))})

# Vector segments: origin (0,0) → each complex number
arrow_rows = []
for _, row in df.iterrows():
    arrow_rows.append({"x": 0, "y": 0, "group": row["label"], "order": 0, "category": row["category"]})
    arrow_rows.append(
        {"x": row["real"], "y": row["imaginary"], "group": row["label"], "order": 1, "category": row["category"]}
    )
arrow_df = pd.DataFrame(arrow_rows)

# Arrowhead positions: pulled slightly back along the vector toward origin
head_offset = 0.08
arrowhead_rows = []
for _, row in df.iterrows():
    rx, iy = row["real"], row["imaginary"]
    mag = np.sqrt(rx**2 + iy**2)
    scale = head_offset / mag if mag > 0 else 0
    hx, hy = rx - scale * rx, iy - scale * iy
    vega_angle = 90 - np.degrees(np.arctan2(iy, rx))
    arrowhead_rows.append({"x": hx, "y": hy, "angle": vega_angle, "category": row["category"]})
arrowhead_df = pd.DataFrame(arrowhead_rows)

# Rotation arc: curved path from z to z·e^(iπ/4) at 55% of vector length
arc_start = np.arctan2(z_original.imag, z_original.real)
arc_end = arc_start + np.pi / 4
arc_theta = np.linspace(arc_start, arc_end, 40)
arc_r = abs(z_original) * 0.55
arc_df = pd.DataFrame({"x": arc_r * np.cos(arc_theta), "y": arc_r * np.sin(arc_theta), "order": range(40)})

# Axis range — expanded slightly so z₁ annotation label (label_x≈2.68) stays within domain
axis_limit = 2.75

# Axis lines through origin (real = horizontal, imaginary = vertical)
axis_line_data = pd.DataFrame(
    {
        "x": [-axis_limit, axis_limit, 0, 0],
        "y": [0, 0, -axis_limit, axis_limit],
        "axis": ["real", "real", "imag", "imag"],
        "order": [0, 1, 0, 1],
    }
)

# Color scale: Imprint positions 1→3 (green, lavender, blue)
cat_domain = ["Roots of Unity", "Arbitrary", "Transformation"]
cat_colors = [IMPRINT_PALETTE[0], IMPRINT_PALETTE[1], IMPRINT_PALETTE[2]]
color_scale = alt.Scale(domain=cat_domain, range=cat_colors)

# Interactive legend selection — click to highlight by category
highlight = alt.selection_point(fields=["category"], bind="legend")
opacity_cond = alt.condition(highlight, alt.value(1.0), alt.value(0.25))

# ── Layers ───────────────────────────────────────────────────────────────────

# Axis lines through origin (structural reference)
axes = (
    alt.Chart(axis_line_data)
    .mark_line(color=INK_MUTED, strokeWidth=1.5, opacity=0.6)
    .encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), detail="axis:N", order="order:O")
)

# Dashed unit circle reference
unit_circle = (
    alt.Chart(circle_df)
    .mark_line(color=INK_SOFT, strokeWidth=2.0, strokeDash=[8, 6], opacity=0.5)
    .encode(x="x:Q", y="y:Q", order="order:O")
)

# Dashed arc showing the π/4 rotation angle
rotation_arc = (
    alt.Chart(arc_df)
    .mark_line(color=IMPRINT_PALETTE[2], strokeWidth=2.5, strokeDash=[5, 3], opacity=0.75)
    .encode(x="x:Q", y="y:Q", order="order:O")
)

# "π/4" label at arc midpoint
arc_mid_angle = arc_start + np.pi / 8
arc_label_df = pd.DataFrame(
    {"x": [arc_r * np.cos(arc_mid_angle) - 0.15], "y": [arc_r * np.sin(arc_mid_angle) + 0.16], "text": ["π/4"]}
)
arc_label = (
    alt.Chart(arc_label_df)
    .mark_text(fontSize=12, fontStyle="italic", fontWeight="bold", color=INK_SOFT, opacity=0.9)
    .encode(x="x:Q", y="y:Q", text="text:N")
)

# Vector lines from origin to each complex number
vectors = (
    alt.Chart(arrow_df)
    .mark_line(strokeWidth=2)
    .encode(
        x="x:Q",
        y="y:Q",
        detail="group:N",
        order="order:O",
        color=alt.Color("category:N", scale=color_scale, legend=None),
        opacity=opacity_cond,
    )
    .add_params(highlight)
)

# Triangular arrowheads — sized larger for full-resolution visibility
arrowheads = (
    alt.Chart(arrowhead_df)
    .mark_point(shape="triangle-up", filled=True, size=400)
    .encode(
        x="x:Q",
        y="y:Q",
        angle=alt.Angle("angle:Q"),
        color=alt.Color("category:N", scale=color_scale, legend=None),
        opacity=opacity_cond,
    )
    .add_params(highlight)
)

# Shared axis configuration (applied to both x and y via the points layer)
axis_cfg = {
    "tickCount": 11,
    "labelFontSize": 10,
    "titleFontSize": 12,
    "gridDash": [3, 3],
    "gridOpacity": 0.12,
    "titleColor": INK,
    "labelColor": INK_SOFT,
    "domainColor": INK_SOFT,
    "tickColor": INK_SOFT,
}

# Scatter points with PAGE_BG stroke for definition on both themes
points = (
    alt.Chart(df)
    .mark_point(filled=True, size=250, stroke=PAGE_BG, strokeWidth=2)
    .encode(
        x=alt.X(
            "real:Q", title="Real Axis", scale=alt.Scale(domain=[-axis_limit, axis_limit]), axis=alt.Axis(**axis_cfg)
        ),
        y=alt.Y(
            "imaginary:Q",
            title="Imaginary Axis",
            scale=alt.Scale(domain=[-axis_limit, axis_limit]),
            axis=alt.Axis(**axis_cfg),
        ),
        color=alt.Color(
            "category:N",
            scale=color_scale,
            legend=alt.Legend(
                title="Category",
                titleFontSize=10,
                labelFontSize=10,
                symbolType="circle",
                symbolSize=200,
                symbolStrokeWidth=0,
                orient="top-right",
                titleColor=INK,
                labelColor=INK_SOFT,
                fillColor=ELEVATED_BG,
                strokeColor=INK_SOFT,
            ),
        ),
        opacity=opacity_cond,
        tooltip=[
            alt.Tooltip("label:N", title="Label"),
            alt.Tooltip("rect_form:N", title="Value"),
            alt.Tooltip("category:N", title="Category"),
        ],
    )
    .add_params(highlight)
)

# Point labels: label + rectangular form annotation
annotations = (
    alt.Chart(df)
    .mark_text(fontSize=10, fontWeight="bold", color=INK)
    .encode(x="label_x:Q", y="label_y:Q", text="annotation:N", opacity=opacity_cond)
    .add_params(highlight)
)

# "Re" and "Im" italic labels at axis endpoints
axis_labels_df = pd.DataFrame({"x": [axis_limit - 0.15, 0.22], "y": [-0.22, axis_limit - 0.10], "text": ["Re", "Im"]})
axis_labels = (
    alt.Chart(axis_labels_df)
    .mark_text(fontSize=12, fontStyle="italic", fontWeight="bold", color=INK_MUTED)
    .encode(x="x:Q", y="y:Q", text="text:N")
)

# Compose all layers
title_text = "scatter-complex-plane · python · altair · anyplot.ai"
chart = (
    alt.layer(axes, unit_circle, rotation_arc, arc_label, vectors, arrowheads, points, annotations, axis_labels)
    .properties(
        width=460,
        height=460,
        background=PAGE_BG,
        title=alt.Title(
            title_text,
            fontSize=16,
            anchor="middle",
            color=INK,
            subtitle="Roots of unity, arbitrary points & rotation (z → z·e^(iπ/4)) in the complex plane",
            subtitleFontSize=12,
            subtitleColor=INK_SOFT,
        ),
    )
    .resolve_scale(color="independent")
    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=460, continuousHeight=460)
    .configure_axis(titlePadding=14)
    .configure_legend(fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK)
    .interactive()
)

# Save PNG and HTML (theme-suffixed filenames required by pipeline)
chart.save(f"plot-{THEME}.png", scale_factor=4.0)
chart.save(f"plot-{THEME}.html")

# Pad PNG to exact 2400×2400 target (square canvas — equal aspect ratio spec)
TW, TH = 2400, 2400
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. "
        f"Shrink chart .properties(width=, height=) values and re-render."
    )
if _w < TW or _h < TH:
    _bg_rgb = tuple(int(PAGE_BG.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
    _canvas = Image.new("RGB", (TW, TH), _bg_rgb)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

Part of Complex Plane Visualization (Argand Diagram) on anyplot.ai.

Other implementations