Star Chart with Constellations — Matplotlib

A celestial map that plots stars on a sky projection (stereographic or azimuthal equidistant) with constellation stick-figure lines connecting notable stars. Star apparent magnitudes are represented by point size (brighter stars appear larger), and a coordinate grid overlays the chart for orientation. The dark background mimics a night sky, making this an intuitive tool for identifying constellations and planning observations.

Star Chart with Constellations rendered with Matplotlib

Python source (Matplotlib)

""" anyplot.ai
star-chart-constellation: Star Chart with Constellations
Library: matplotlib 3.11.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-06-17
"""

import os

import matplotlib.patheffects as pe
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.lines import Line2D
from matplotlib.patches import Patch


# Theme tokens (see prompts/default-style-guide.md "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"
BRAND = "#009E73"  # Imprint palette position 1 — brightest stars
AMBER = "#DDCC77"  # semantic anchor — ecliptic reference line

np.random.seed(42)

# Star catalog: name -> (RA hours, Dec degrees, magnitude, constellation abbrev)
stars = {
    # Orion
    "Betelgeuse": (5.92, 7.41, 0.42, "Ori"),
    "Rigel": (5.24, -8.20, 0.13, "Ori"),
    "Bellatrix": (5.42, 6.35, 1.64, "Ori"),
    "Mintaka": (5.53, -0.30, 2.23, "Ori"),
    "Alnilam": (5.60, -1.20, 1.69, "Ori"),
    "Alnitak": (5.68, -1.94, 1.77, "Ori"),
    "Saiph": (5.80, -9.67, 2.09, "Ori"),
    # Ursa Major
    "Dubhe": (11.06, 61.75, 1.79, "UMa"),
    "Merak": (11.03, 56.38, 2.37, "UMa"),
    "Phecda": (11.90, 53.69, 2.44, "UMa"),
    "Megrez": (12.26, 57.03, 3.31, "UMa"),
    "Alioth": (12.90, 55.96, 1.77, "UMa"),
    "Mizar": (13.40, 54.93, 2.27, "UMa"),
    "Alkaid": (13.79, 49.31, 1.86, "UMa"),
    # Cassiopeia
    "Schedar": (0.68, 56.54, 2.23, "Cas"),
    "Caph": (0.15, 59.15, 2.27, "Cas"),
    "Gamma Cas": (0.95, 60.72, 2.47, "Cas"),
    "Ruchbah": (1.36, 60.24, 2.68, "Cas"),
    "Segin": (1.91, 63.67, 3.37, "Cas"),
    # Leo
    "Regulus": (10.14, 11.97, 1.35, "Leo"),
    "Denebola": (11.82, 14.57, 2.13, "Leo"),
    "Algieba": (10.33, 19.84, 2.08, "Leo"),
    "Zosma": (11.24, 20.52, 2.56, "Leo"),
    "Chertan": (11.24, 15.43, 3.33, "Leo"),
    "Eta Leo": (10.12, 16.76, 3.52, "Leo"),
    # Scorpius
    "Antares": (16.49, -26.43, 1.09, "Sco"),
    "Shaula": (17.56, -37.10, 1.63, "Sco"),
    "Sargas": (17.62, -42.99, 1.87, "Sco"),
    "Dschubba": (16.01, -22.62, 2.32, "Sco"),
    "Acrab": (16.09, -19.81, 2.62, "Sco"),
    "Wei": (16.84, -34.29, 2.29, "Sco"),
    "Lesath": (17.53, -37.29, 2.69, "Sco"),
    # Cygnus
    "Deneb": (20.69, 45.28, 1.25, "Cyg"),
    "Sadr": (20.37, 40.26, 2.20, "Cyg"),
    "Gienah Cyg": (20.77, 33.97, 2.46, "Cyg"),
    "Delta Cyg": (19.75, 45.13, 2.87, "Cyg"),
    "Albireo": (19.51, 27.96, 3.08, "Cyg"),
    # Lyra
    "Vega": (18.62, 38.78, 0.03, "Lyr"),
    "Sheliak": (18.83, 33.36, 3.45, "Lyr"),
    "Sulafat": (18.98, 32.69, 3.24, "Lyr"),
    "Epsilon1 Lyr": (18.74, 39.67, 4.67, "Lyr"),
    "Epsilon2 Lyr": (18.75, 39.61, 4.59, "Lyr"),
    # Gemini
    "Pollux": (7.76, 28.03, 1.14, "Gem"),
    "Castor": (7.58, 31.89, 1.58, "Gem"),
    "Alhena": (6.63, 16.40, 1.93, "Gem"),
    "Wasat": (7.07, 21.98, 3.53, "Gem"),
    "Mebsuta": (6.73, 25.13, 2.98, "Gem"),
    "Tejat": (6.38, 22.51, 2.88, "Gem"),
    # Taurus
    "Aldebaran": (4.60, 16.51, 0.85, "Tau"),
    "Elnath": (5.44, 28.61, 1.68, "Tau"),
    "Alcyone": (3.79, 24.11, 2.87, "Tau"),
    "Tianguan": (5.63, 21.14, 3.00, "Tau"),
    "Prima Hyadum": (4.33, 15.63, 3.65, "Tau"),
    "Ain": (4.48, 19.18, 3.54, "Tau"),
    # Canis Major
    "Sirius": (6.75, -16.72, -1.46, "CMa"),
    "Adhara": (6.98, -28.97, 1.50, "CMa"),
    "Wezen": (7.14, -26.39, 1.84, "CMa"),
    "Mirzam": (6.38, -17.96, 1.98, "CMa"),
    "Aludra": (7.40, -29.30, 2.45, "CMa"),
    "Furud": (6.34, -30.06, 3.02, "CMa"),
    # Aquila
    "Altair": (19.85, 8.87, 0.76, "Aql"),
    "Tarazed": (19.77, 10.61, 2.72, "Aql"),
    "Alshain": (19.92, 6.41, 3.71, "Aql"),
    "Theta Aql": (20.19, -0.82, 3.23, "Aql"),
    "Delta Aql": (19.42, 3.11, 3.36, "Aql"),
    # Bootes
    "Arcturus": (14.26, 19.18, -0.05, "Boo"),
    "Izar": (14.75, 27.07, 2.37, "Boo"),
    "Muphrid": (13.91, 18.40, 2.68, "Boo"),
    "Nekkar": (15.03, 40.39, 3.58, "Boo"),
    "Seginus": (14.53, 38.31, 3.03, "Boo"),
    # Perseus
    "Mirfak": (3.41, 49.86, 1.80, "Per"),
    "Algol": (3.14, 40.96, 2.12, "Per"),
    "Zeta Per": (3.90, 31.88, 2.85, "Per"),
    "Epsilon Per": (3.96, 40.01, 2.89, "Per"),
    "Delta Per": (3.72, 47.79, 3.01, "Per"),
    # Auriga
    "Capella": (5.27, 45.99, 0.08, "Aur"),
    "Menkalinan": (5.99, 44.95, 1.90, "Aur"),
    "Theta Aur": (5.99, 37.21, 2.62, "Aur"),
    "Hassaleh": (4.95, 33.17, 2.69, "Aur"),
    "Almaaz": (5.03, 43.82, 2.99, "Aur"),
    # Virgo
    "Spica": (13.42, -11.16, 0.97, "Vir"),
    "Porrima": (12.69, -1.45, 2.74, "Vir"),
    "Vindemiatrix": (13.04, 10.96, 2.83, "Vir"),
    "Heze": (13.58, -0.60, 3.37, "Vir"),
    "Zaniah": (12.33, -0.67, 3.89, "Vir"),
    # Corona Borealis
    "Alphecca": (15.58, 26.71, 2.23, "CrB"),
    "Nusakan": (15.46, 29.11, 3.68, "CrB"),
    "Gamma CrB": (15.71, 26.30, 3.84, "CrB"),
    "Delta CrB": (15.83, 26.07, 4.59, "CrB"),
    # Pegasus
    "Enif": (21.74, 9.88, 2.39, "Peg"),
    "Markab": (23.08, 15.21, 2.49, "Peg"),
    "Scheat": (23.06, 28.08, 2.42, "Peg"),
    "Algenib": (0.22, 15.18, 2.83, "Peg"),
    "Matar": (22.72, 30.22, 2.94, "Peg"),
    # Andromeda
    "Alpheratz": (0.14, 29.09, 2.06, "And"),
    "Mirach": (1.16, 35.62, 2.05, "And"),
    "Almach": (2.07, 42.33, 2.17, "And"),
    # Draco
    "Eltanin": (17.94, 51.49, 2.24, "Dra"),
    "Rastaban": (17.51, 52.30, 2.79, "Dra"),
    "Thuban": (14.07, 64.38, 3.65, "Dra"),
    "Grumium": (17.89, 56.87, 3.75, "Dra"),
    "Kuma": (17.53, 55.17, 4.87, "Dra"),
    # Hercules
    "Kornephoros": (16.50, 21.49, 2.77, "Her"),
    "Zeta Her": (16.69, 31.60, 2.81, "Her"),
    "Rasalgethi": (17.24, 14.39, 3.37, "Her"),
    "Sarin": (17.25, 24.84, 3.14, "Her"),
    "Pi Her": (17.25, 36.81, 3.16, "Her"),
    "Eta Her": (16.71, 38.92, 3.53, "Her"),
    # Sagittarius
    "Kaus Australis": (18.40, -34.38, 1.85, "Sgr"),
    "Nunki": (18.92, -26.30, 2.02, "Sgr"),
    "Ascella": (19.04, -29.88, 2.59, "Sgr"),
    "Kaus Media": (18.35, -29.83, 2.70, "Sgr"),
    "Kaus Borealis": (18.47, -25.42, 2.81, "Sgr"),
    "Nash": (18.10, -30.42, 3.11, "Sgr"),
}

# Constellation stick-figure edges
edges = [
    # Orion
    ("Betelgeuse", "Bellatrix"),
    ("Betelgeuse", "Mintaka"),
    ("Bellatrix", "Mintaka"),
    ("Mintaka", "Alnilam"),
    ("Alnilam", "Alnitak"),
    ("Rigel", "Alnitak"),
    ("Rigel", "Saiph"),
    ("Saiph", "Alnitak"),
    # Ursa Major (Big Dipper)
    ("Dubhe", "Merak"),
    ("Merak", "Phecda"),
    ("Phecda", "Megrez"),
    ("Megrez", "Alioth"),
    ("Alioth", "Mizar"),
    ("Mizar", "Alkaid"),
    ("Megrez", "Dubhe"),
    # Cassiopeia
    ("Caph", "Schedar"),
    ("Schedar", "Gamma Cas"),
    ("Gamma Cas", "Ruchbah"),
    ("Ruchbah", "Segin"),
    # Leo
    ("Regulus", "Eta Leo"),
    ("Eta Leo", "Algieba"),
    ("Algieba", "Zosma"),
    ("Zosma", "Denebola"),
    ("Regulus", "Chertan"),
    ("Chertan", "Denebola"),
    # Scorpius
    ("Acrab", "Dschubba"),
    ("Dschubba", "Antares"),
    ("Antares", "Wei"),
    ("Wei", "Shaula"),
    ("Shaula", "Lesath"),
    ("Wei", "Sargas"),
    # Cygnus (Northern Cross)
    ("Deneb", "Sadr"),
    ("Sadr", "Albireo"),
    ("Sadr", "Gienah Cyg"),
    ("Sadr", "Delta Cyg"),
    # Lyra
    ("Vega", "Sheliak"),
    ("Vega", "Sulafat"),
    ("Sheliak", "Sulafat"),
    # Gemini
    ("Castor", "Pollux"),
    ("Castor", "Mebsuta"),
    ("Mebsuta", "Tejat"),
    ("Pollux", "Wasat"),
    ("Wasat", "Alhena"),
    # Taurus
    ("Aldebaran", "Ain"),
    ("Ain", "Prima Hyadum"),
    ("Aldebaran", "Elnath"),
    ("Ain", "Alcyone"),
    ("Elnath", "Tianguan"),
    # Canis Major
    ("Sirius", "Mirzam"),
    ("Sirius", "Adhara"),
    ("Adhara", "Wezen"),
    ("Wezen", "Aludra"),
    ("Adhara", "Furud"),
    # Aquila
    ("Altair", "Tarazed"),
    ("Altair", "Alshain"),
    ("Altair", "Theta Aql"),
    ("Altair", "Delta Aql"),
    # Bootes
    ("Arcturus", "Muphrid"),
    ("Arcturus", "Izar"),
    ("Izar", "Seginus"),
    ("Seginus", "Nekkar"),
    # Perseus
    ("Mirfak", "Delta Per"),
    ("Mirfak", "Epsilon Per"),
    ("Epsilon Per", "Algol"),
    ("Algol", "Zeta Per"),
    # Auriga
    ("Capella", "Menkalinan"),
    ("Menkalinan", "Theta Aur"),
    ("Theta Aur", "Hassaleh"),
    ("Hassaleh", "Almaaz"),
    ("Almaaz", "Capella"),
    # Virgo
    ("Spica", "Heze"),
    ("Heze", "Porrima"),
    ("Porrima", "Zaniah"),
    ("Porrima", "Vindemiatrix"),
    # Corona Borealis
    ("Alphecca", "Nusakan"),
    ("Alphecca", "Gamma CrB"),
    ("Gamma CrB", "Delta CrB"),
    # Pegasus (Great Square)
    ("Markab", "Scheat"),
    ("Scheat", "Algenib"),
    ("Markab", "Enif"),
    ("Markab", "Algenib"),
    # Andromeda
    ("Alpheratz", "Mirach"),
    ("Mirach", "Almach"),
    ("Alpheratz", "Scheat"),
    # Draco
    ("Eltanin", "Rastaban"),
    ("Rastaban", "Kuma"),
    ("Kuma", "Grumium"),
    ("Eltanin", "Grumium"),
    # Hercules
    ("Kornephoros", "Zeta Her"),
    ("Zeta Her", "Eta Her"),
    ("Eta Her", "Pi Her"),
    ("Kornephoros", "Rasalgethi"),
    ("Rasalgethi", "Sarin"),
    ("Sarin", "Pi Her"),
    # Sagittarius (Teapot)
    ("Kaus Australis", "Kaus Media"),
    ("Kaus Media", "Kaus Borealis"),
    ("Kaus Borealis", "Nunki"),
    ("Nunki", "Ascella"),
    ("Ascella", "Kaus Australis"),
    ("Nash", "Kaus Media"),
]

# Limit of the circular sky chart: declination floor (stars below this are off-map)
DEC_FLOOR = -48.0
R_MAX = 90.0 - DEC_FLOOR  # radial distance of the chart boundary


# North-polar azimuthal-equidistant projection: equally spaced declination
# circles give a natural *circular* sky boundary (the spec's preferred style).
#   theta = RA (radians)   r = colatitude = 90deg - Dec
def project(ra_h, dec_d):
    theta = np.asarray(ra_h, dtype=float) * (np.pi / 12.0)
    r = 90.0 - np.asarray(dec_d, dtype=float)
    return theta, r


# Background field stars (faint, for celestial-sphere context)
n_bg = 320
bg_ra_hours = np.random.uniform(0, 24, n_bg)
bg_dec_deg = np.random.uniform(DEC_FLOOR, 72, n_bg)
bg_mag = np.random.uniform(4.0, 6.0, n_bg)
bg_theta, bg_r = project(bg_ra_hours, bg_dec_deg)

# Milky Way band — soft cloud of faint points along the galactic plane.
# Convert galactic (l, b) -> equatorial (RA, Dec) via the standard NGP frame.
ra_ngp, dec_ngp, l_ncp = np.radians(192.85948), np.radians(27.12825), np.radians(122.93192)
n_mw = 2600
mw_l = np.random.uniform(0, 2 * np.pi, n_mw)
mw_b = np.random.normal(0.0, np.radians(8.5), n_mw)  # band thickness ~ +/-8.5deg
mw_dec = np.arcsin(np.sin(dec_ngp) * np.sin(mw_b) + np.cos(dec_ngp) * np.cos(mw_b) * np.cos(l_ncp - mw_l))
mw_ra = ra_ngp + np.arctan2(
    np.cos(mw_b) * np.sin(l_ncp - mw_l),
    np.sin(mw_b) * np.cos(dec_ngp) - np.cos(mw_b) * np.sin(dec_ngp) * np.cos(l_ncp - mw_l),
)
mw_theta, mw_r = project(np.degrees(mw_ra) / 15.0, np.degrees(mw_dec))

# Extract named-star arrays
star_names = list(stars.keys())
star_ra_h = np.array([stars[s][0] for s in star_names])
star_dec_d = np.array([stars[s][1] for s in star_names])
star_mag = np.array([stars[s][2] for s in star_names])
star_theta, star_r = project(star_ra_h, star_dec_d)

# Size mapping: brighter stars (lower magnitude) map to larger points
max_mag, min_mag = 6.5, -1.5
min_size, max_size = 5, 175
star_sizes = max_size * ((max_mag - star_mag) / (max_mag - min_mag)) ** 1.5 + min_size

# Color mapping by magnitude — Imprint sequential cmap (brand green -> blue).
# Brightest stars (low magnitude) get brand green; faintest fade to blue.
imprint_seq = LinearSegmentedColormap.from_list("imprint_seq", [BRAND, "#4467A3"])
star_color_norm = np.clip((star_mag - min_mag) / (max_mag - min_mag), 0, 1)
star_colors = imprint_seq(star_color_norm)

# Plot — square axes centered in the 3200x1800 canvas so the polar plot reads
# as a true circle, leaving the side margins for the legends.
fig = plt.figure(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)
ax = fig.add_axes([0.305, 0.085, 0.39, 0.83], projection="polar", facecolor=PAGE_BG)
ax.set_theta_zero_location("N")
ax.set_theta_direction(-1)  # RA increases clockwise from the top
ax.set_rlim(0, R_MAX)
ax.set_rorigin(0)

# Milky Way band (deepest layer) — faint diffuse glow
ax.scatter(mw_theta, mw_r, s=7, c=INK_MUTED, alpha=0.045, edgecolors="none", zorder=0)

# Faint background field stars (context layer, muted)
bg_sizes = max_size * ((max_mag - bg_mag) / (max_mag - min_mag)) ** 1.5 + min_size
ax.scatter(bg_theta, bg_r, s=bg_sizes, c=INK_MUTED, alpha=0.30, edgecolors="none", zorder=1)

# Constellation stick-figure lines (thin, semi-transparent structural ink)
for s1, s2 in edges:
    t1, r1 = project(stars[s1][0], stars[s1][1])
    t2, r2 = project(stars[s2][0], stars[s2][1])
    # Unwrap so the segment takes the short way around the RA circle
    if t2 - t1 > np.pi:
        t2 -= 2 * np.pi
    elif t1 - t2 > np.pi:
        t2 += 2 * np.pi
    ax.plot([t1, t2], [r1, r2], color=INK_SOFT, linewidth=0.8, alpha=0.5, zorder=2)

# Soft halo behind the brightest stars (mag < 1.0) for a gentle glow
bright_mask = star_mag < 1.0
ax.scatter(
    star_theta[bright_mask],
    star_r[bright_mask],
    s=star_sizes[bright_mask] * 2.6,
    c=BRAND,
    alpha=0.10,
    edgecolors="none",
    zorder=2,
)

# Named stars — sized by magnitude, colored along the Imprint sequential ramp
ax.scatter(star_theta, star_r, s=star_sizes, c=star_colors, alpha=0.95, edgecolors=PAGE_BG, linewidth=0.4, zorder=3)

# Ecliptic — dashed amber reference curve. The ecliptic is the great circle
# 23.44deg from the ecliptic pole; trace it in RA/Dec then project.
eps = np.radians(23.44)
ecl_lon = np.linspace(0, 2 * np.pi, 720)
ecl_dec = np.degrees(np.arcsin(np.sin(eps) * np.sin(ecl_lon)))
ecl_ra_h = (np.degrees(np.arctan2(np.cos(eps) * np.sin(ecl_lon), np.cos(ecl_lon))) / 15.0) % 24
ecl_theta, ecl_r = project(ecl_ra_h, ecl_dec)
# Break the curve where it wraps past the RA seam to avoid a chord across the chart
seam = np.abs(np.diff(ecl_theta)) > np.pi
ecl_theta[:-1][seam] = np.nan
ax.plot(ecl_theta, ecl_r, color=AMBER, linewidth=1.4, alpha=0.8, linestyle=(0, (7, 4)), zorder=2)

# Label the brightest stars (mag < 1.0)
bright_offsets = {
    "Sirius": (7, -10),
    "Vega": (8, 6),
    "Arcturus": (8, -4),
    "Capella": (-6, 9),
    "Rigel": (7, -10),
    "Betelgeuse": (8, 6),
    "Altair": (8, -8),
    "Aldebaran": (-10, 8),
    "Spica": (7, -10),
    "Pollux": (8, 7),
}
for name, (ra, dec, mag, _) in stars.items():
    if mag < 1.0:
        t, r = project(ra, dec)
        ax.annotate(
            name,
            (t, r),
            fontsize=7.5,
            color=INK,
            alpha=0.9,
            xytext=bright_offsets.get(name, (7, 6)),
            textcoords="offset points",
            zorder=5,
            path_effects=[pe.withStroke(linewidth=2, foreground=PAGE_BG, alpha=0.8)],
        )

# Constellation names placed near each group's centroid (circular mean in RA)
constellation_names = {
    "Ori": "Orion",
    "UMa": "Ursa Major",
    "Cas": "Cassiopeia",
    "Leo": "Leo",
    "Sco": "Scorpius",
    "Cyg": "Cygnus",
    "Lyr": "Lyra",
    "Gem": "Gemini",
    "Tau": "Taurus",
    "CMa": "Canis Major",
    "Aql": "Aquila",
    "Boo": "Boötes",
    "Per": "Perseus",
    "Aur": "Auriga",
    "Vir": "Virgo",
    "CrB": "Corona Bor.",
    "Peg": "Pegasus",
    "And": "Andromeda",
    "Dra": "Draco",
    "Her": "Hercules",
    "Sgr": "Sagittarius",
}

# Seed each label at its constellation centroid (circular mean handles the
# RA=0h seam), then relax overlaps with a force-based pass in projected
# Cartesian space so the dense northern groups spread out legibly.
label_seeds = []
for abbr, full_name in constellation_names.items():
    member_t = np.array([project(stars[s][0], stars[s][1])[0] for s in star_names if stars[s][3] == abbr])
    member_r = np.array([project(stars[s][0], stars[s][1])[1] for s in star_names if stars[s][3] == abbr])
    ct = np.arctan2(np.mean(np.sin(member_t)), np.mean(np.cos(member_t)))
    cr = np.mean(member_r)
    label_seeds.append((ct, cr, full_name))

xy = np.array([[r * np.cos(t), r * np.sin(t)] for t, r, _ in label_seeds])
# Width-aware half-extents (data units) so long names reserve more room.
half_w = np.array([len(full) * 0.95 + 2.0 for _, _, full in label_seeds])
# Fixed obstacles: bright-star labels keep their place, so push names off them.
fixed_xy = np.array(
    [
        [project(ra, dec)[1] * np.cos(project(ra, dec)[0]), project(ra, dec)[1] * np.sin(project(ra, dec)[0])]
        for ra, dec, mag, _ in stars.values()
        if mag < 1.0
    ]
)
MIN_D_FIXED = 12.0
for _ in range(200):
    moved = False
    for i in range(len(xy)):
        for j in range(i + 1, len(xy)):
            d = xy[i] - xy[j]
            dist = np.hypot(d[0], d[1])
            need = half_w[i] + half_w[j]
            if 1e-6 < dist < need:
                shift = (d / dist) * (need - dist) / 2.0
                xy[i] += shift
                xy[j] -= shift
                moved = True
        for fx in fixed_xy:
            d = xy[i] - fx
            dist = np.hypot(d[0], d[1])
            if 1e-6 < dist < MIN_D_FIXED:
                xy[i] += (d / dist) * (MIN_D_FIXED - dist)
                moved = True
    if not moved:
        break

for (x, y), (_, _, full_name) in zip(xy, label_seeds, strict=True):
    nr = min(np.hypot(x, y), R_MAX - 4.0)
    nt = np.arctan2(y, x)
    ax.text(
        nt,
        nr,
        full_name,
        fontsize=8,
        color=INK_SOFT,
        alpha=0.9,
        ha="center",
        va="center",
        fontstyle="italic",
        fontweight="medium",
        zorder=4,
        path_effects=[pe.withStroke(linewidth=2.5, foreground=PAGE_BG, alpha=0.7)],
    )

# Style the circular coordinate grid (RA spokes + Dec circles)
ax.grid(True, color=INK, linewidth=0.5, alpha=0.16, linestyle=(0, (4, 6)))
ax.set_axisbelow(True)
ax.spines["polar"].set_color(INK_SOFT)
ax.spines["polar"].set_alpha(0.5)
ax.spines["polar"].set_linewidth(1.0)

# RA spokes every 2 hours, labelled in hours
ax.set_xticks(np.arange(0, 2 * np.pi, np.pi / 6))
ax.set_xticklabels([f"{(2 * k) % 24}h" for k in range(12)])

# Dec circles: r = 90 - Dec  ->  +60, +30, 0 (equator), -30
ax.set_rticks([30, 60, 90, 120])
ax.set_yticklabels(["+60°", "+30°", "0°", "−30°"])
ax.set_rlabel_position(99)
ax.tick_params(axis="x", colors=INK_SOFT, labelsize=8, pad=2)
ax.tick_params(axis="y", colors=INK_MUTED, labelsize=7)
for lbl in ax.get_yticklabels():
    lbl.set_path_effects([pe.withStroke(linewidth=2, foreground=PAGE_BG, alpha=0.7)])

# Feature legend (ecliptic + Milky Way) — upper-right margin
feature_handles = [
    Line2D([0], [0], color=AMBER, lw=1.4, linestyle=(0, (5, 3)), label="Ecliptic"),
    Patch(facecolor=INK_MUTED, alpha=0.35, edgecolor="none", label="Milky Way"),
]
leg1 = ax.legend(
    handles=feature_handles,
    loc="upper left",
    bbox_to_anchor=(1.04, 1.02),
    fontsize=8,
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    framealpha=0.9,
    handlelength=1.8,
    borderpad=0.7,
)
plt.setp(leg1.get_texts(), color=INK_SOFT)
ax.add_artist(leg1)

# Magnitude size legend — lower-right margin (size encodes brightness)
mag_legend = [0.0, 2.0, 4.0]
mag_labels = ["0  (bright)", "2", "4  (faint)"]
size_handles = [
    Line2D(
        [0],
        [0],
        marker="o",
        color="none",
        markerfacecolor=imprint_seq((m - min_mag) / (max_mag - min_mag)),
        markeredgecolor=PAGE_BG,
        markeredgewidth=0.4,
        markersize=np.sqrt(max_size * ((max_mag - m) / (max_mag - min_mag)) ** 1.5 + min_size),
        label=lab,
    )
    for m, lab in zip(mag_legend, mag_labels, strict=True)
]
leg2 = ax.legend(
    handles=size_handles,
    loc="lower left",
    bbox_to_anchor=(1.04, -0.02),
    fontsize=8,
    title="Apparent magnitude",
    facecolor=ELEVATED_BG,
    edgecolor=INK_SOFT,
    framealpha=0.9,
    labelspacing=1.1,
    borderpad=0.8,
    handletextpad=1.0,
)
plt.setp(leg2.get_texts(), color=INK_SOFT)
leg2.get_title().set_color(INK)
leg2.get_title().set_fontsize(8)

# Title + footer subtitle
fig.text(
    0.5,
    0.95,
    "star-chart-constellation · python · matplotlib · anyplot.ai",
    ha="center",
    fontsize=12,
    fontweight="medium",
    color=INK,
)
fig.text(
    0.5,
    0.035,
    "Azimuthal-equidistant sky chart  |  Right Ascension (hours) · Declination (°)  |  point size ∝ brightness",
    ha="center",
    fontsize=9,
    color=INK_SOFT,
)

plt.savefig(f"plot-{THEME}.png", dpi=400, facecolor=fig.get_facecolor())

Part of Star Chart with Constellations on anyplot.ai.

Other implementations