Walldye

Star chart

inspired by Arthur Philip Norton, Norton’s Star Atlas1 and Johann Bayer, Uranometria2

Cassiopeia’s W on a stereographic atlas page, Schedar circled, grid lines curving round the unseen pole.

Made with Claude Opus 5.5

Technique
technical drawing
Inspired by
scientific illustration
Shape
Any screen
Added
27 September 2026

Versions

Colours

  • #1C1B1Abackground
  • #DAD8CEforeground
  • #CF6A4Caccent

Export

Format
Shape
Size

Notes

Star positions and magnitudes, down to magnitude 7.3, come from XHIP, an extended Hipparcos compilation, as packaged by d3-celestial. A stereographic projection keeps every circle on the sky a circle on the page, so each coordinate line is a circular arc.

Sources

  1. Arthur Philip Norton, Norton’s Star Atlas, 1910. ↑
  2. Johann Bayer, Uranometria, 1603. ↑
  3. E. Anderson and C. Francis, XHIP: An Extended Hipparcos Compilation, 2012.
  4. d3-celestial.

Source code

wallpapers/star-chart/design.py, 184 lines

"""A stereographic star atlas: an RA/Dec graticule and the galactic equator over Hipparcos stars dotted by magnitude, with one constellation traced."""

import itertools
from typing import Literal, TypedDict

import numpy as np
from numpy.typing import NDArray

from walldye import (
    ACCENT,
    ACCENT_2,
    BG_ALT,
    UI,
    UI_ALT,
    UI_HI,
    Canvas,
    P,
    Params,
    Path,
    Rect,
    Vec,
    design,
    knob,
)
from walldye.field import runs
from walldye.geom import Affine

type Arr = NDArray[np.float64]


class Chart(Params):
    sky: Literal["cassiopeia", "orion"] = knob(default="cassiopeia", doc="constellation traced")


VARIANTS = {"orion": Chart(sky="orion")}


class Sky(TypedDict):
    tangent: tuple[float, float]  # RA, Dec (deg) of the projection centre
    focus: tuple[float, float]  # RA, Dec (deg) of the sky point placed on the focal point
    north: float  # bearing of north at the tangent point, on screen
    scale: float  # px per radian at the tangent point
    stars: tuple[tuple[float, float, float], ...]  # the figure's stars: RA, Dec (deg), V mag
    lines: tuple[tuple[int, ...], ...]  # the figure's strokes, as indices into stars
    ring: int  # the circled star


SKIES: dict[str, Sky] = {
    # Cassiopeia's W from epsilon to beta, Schedar (alpha) circled; the pole lies off the top
    "cassiopeia": {
        "tangent": (15.0, 60.0),
        "focus": (15.0, 60.0),
        "north": -22.0,
        "scale": 2600.0,
        "stars": (
            (28.5989, 63.6701, 3.37),
            (21.454, 60.2353, 2.68),
            (14.1772, 60.7167, 2.47),
            (10.1268, 56.5373, 2.24),
            (2.2945, 59.1498, 2.27),
        ),
        "lines": ((0, 1, 2, 3, 4),),
        "ring": 3,
    },
    # Orion's hourglass and head, Betelgeuse (alpha) circled; projected from 35 degrees north
    # of it, so the declination circles still bend
    "orion": {
        "tangent": (84.0, 35.0),
        "focus": (83.7, 0.0),
        "north": -20.0,
        "scale": 1800.0,
        "stars": (
            (88.7929, 7.4071, 0.45),  # Betelgeuse
            (81.2828, 6.3497, 1.64),  # Bellatrix
            (83.0017, -0.2991, 2.25),  # Mintaka
            (84.0534, -1.2019, 1.69),  # Alnilam
            (85.1897, -1.9426, 1.74),  # Alnitak
            (86.9391, -9.6696, 2.07),  # Saiph
            (78.6345, -8.2016, 0.18),  # Rigel
            (83.7845, 9.9342, 3.39),  # Meissa
        ),
        "lines": ((0, 1, 2, 3, 4, 0), (2, 6, 5, 4), (0, 7, 1)),
        "ring": 0,
    },
}

NGP = (192.85948, 27.12825)  # north galactic pole, RA/Dec (J2000)
L_NCP = 122.93192  # galactic longitude of the north celestial pole
# Star dots by V magnitude: stars fainter than the limit get this radius and tone.
DOTS = (
    (7.0, 0.8, UI),
    (6.0, 1.05, UI_ALT),
    (5.0, 1.45, UI_ALT),
    (4.0, 2.0, UI_HI),
    (-9.0, 2.7, UI_HI),
)
RING, GAP = 16, 9  # ring radius; the atlas-style break in a figure line at each star


def stereo(ra: Arr, dec: Arr, tangent: tuple[float, float]) -> Arr:
    """Oblique stereographic projection about `tangent` of the sky seen from inside, so east
    lies to the left of north. Maps RA and Dec in degrees to (N, 2) plane points in radians at
    the tangent point, with +x west and +y south; points over 120 degrees from the tangent
    point come back as NaN."""
    l0, p0 = np.radians(tangent)
    l, p = np.radians(ra), np.radians(dec)
    cos_c = np.sin(p0) * np.sin(p) + np.cos(p0) * np.cos(p) * np.cos(l - l0)
    k = np.where(cos_c > -0.5, 2 / np.maximum(1 + cos_c, 0.5), np.nan)
    west = -k * np.cos(p) * np.sin(l - l0)
    north = k * (np.cos(p0) * np.sin(p) - np.sin(p0) * np.cos(p) * np.cos(l - l0))
    return np.column_stack([west, -north])


def inside(pts: Arr, box: Rect) -> NDArray[np.bool_]:
    """Which of the (N, 2) points lie strictly inside `box`; NaN points never do."""
    x, y = pts[:, 0], pts[:, 1]
    return (x > box.x) & (x < box.x1) & (y > box.y) & (y < box.y1)


def trace(d: Path, pts: Arr, box: Rect) -> None:
    """Add the runs of the sampled curve `pts` that lie inside `box` to `d`."""
    for a, b in runs(inside(pts, box)):
        if b - a > 1:
            d.poly(pts[a:b])


@design(aspects="any", variants=VARIANTS)
def draw(s: Canvas[Chart]) -> None:
    sky = SKIES[s.params.sky]
    spot = s.pick(landscape=(1300 / 1920, 480 / 1080), portrait=(0.52, 0.38))
    focus = np.array([sky["focus"]])
    fx, fy = stereo(focus[:, 0], focus[:, 1], sky["tangent"])[0]
    # plane x (west) points a quarter turn clockwise from north, as the sky looks from below
    frame = Affine.frame(spot, bearing=sky["north"] + 90, scale=sky["scale"])
    frame = frame @ Affine.translate(-fx, -fy)

    def at(ra: Arr, dec: Arr) -> Arr:
        return frame.apply(stereo(ra, dec, sky["tangent"]))

    box = s.inset(-40)
    grid = P()
    t = np.linspace(-180, 180, 1441)
    for dec in range(-80, 90, 10):
        trace(grid, at(t, np.full_like(t, dec)), box)
    t = np.linspace(-89.9, 89.9, 1441)
    for ra in range(0, 360, 15):
        trace(grid, at(np.full_like(t, ra), t), box)
    s.stroke(grid, BG_ALT, 1.2)

    # the galactic equator, which runs through Cassiopeia and just east of Orion
    gl = np.radians(np.linspace(0, 360, 1441))
    ag, dg = np.radians(NGP)
    ln = np.radians(L_NCP)
    dec = np.arcsin(np.cos(dg) * np.cos(ln - gl))
    ra = ag + np.arctan2(np.sin(ln - gl), -np.sin(dg) * np.cos(ln - gl))
    galaxy = P()
    trace(galaxy, at(np.degrees(ra), np.degrees(dec)), box)
    s.stroke(galaxy, UI, 1.2, dash=(3, 9))

    # Hipparcos stars to V 7.3 that fall on some screen shape: RA, Dec (deg), V mag
    stars: list[list[float]] = s.data(f"{s.params.sky}.json")
    cat = np.array(stars)
    xy = at(cat[:, 0], cat[:, 1])
    on = inside(xy, s.inset(-10))
    xy, mag = xy[on], cat[on, 2]
    lo = 99.0
    for hi, r, tone in DOTS:
        s.fill(P().dots(xy[(mag <= lo) & (mag > hi)], r), tone)
        lo = hi

    fig = np.array(sky["stars"])
    pts = [Vec(x, y) for x, y in at(fig[:, 0], fig[:, 1])]
    gaps = [RING + 5 if i == sky["ring"] else GAP for i in range(len(pts))]
    figure = P()
    for line in sky["lines"]:
        for i, j in itertools.pairwise(line):
            u = (pts[j] - pts[i]).unit()
            figure.M(pts[i] + u * gaps[i]).L(pts[j] - u * gaps[j])
    s.stroke(figure, ACCENT_2, 1.5)
    dots = P()
    for p, m in zip(pts, fig[:, 2], strict=True):
        dots.circle(p, 7.5 - m * 1.3)
    s.fill(dots, ACCENT)
    s.stroke(P().circle(pts[sky["ring"]], RING), ACCENT, 1.5)

Run it yourself

$ git clone https://github.com/nickolaj-jepsen/walldye && cd walldye$ uv run walldye render star-chart --theme fireproof -o star-chart-fireproof-16x9.svg