"""
Graphic Ephemeris Visualization (stellium.visualization.ephemeris)
Renders a graphic ephemeris showing planetary positions over time.
The X-axis represents time, the Y-axis represents zodiacal position
(optionally compressed to 90° or 45° harmonic).
Example:
>>> from stellium.visualization import GraphicEphemeris
>>> eph = GraphicEphemeris(
... start_date="2025-01-01",
... end_date="2025-12-31",
... harmonic=90,
... )
>>> eph.draw("ephemeris_2025.svg")
"""
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Literal
import svgwrite
if TYPE_CHECKING:
from stellium.core.models import CalculatedChart
from stellium.visualization.palettes import PlanetGlyphPalette, get_planet_glyph_color
# Default planets to show (outer planets + luminaries for typical use)
DEFAULT_PLANETS = [
"Sun",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
]
# Extended set including Chiron and North Node
EXTENDED_PLANETS = DEFAULT_PLANETS + ["Chiron", "True Node"]
# Mapping planet names to Swiss Ephemeris IDs
PLANET_IDS = {
"Sun": 0,
"Moon": 1,
"Mercury": 2,
"Venus": 3,
"Mars": 4,
"Jupiter": 5,
"Saturn": 6,
"Uranus": 7,
"Neptune": 8,
"Pluto": 9,
"True Node": 11,
"Chiron": 15,
}
# Zodiac sign glyphs (proper astrological symbols, not emojis)
SIGN_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
def _longitude_to_sign_degree(longitude: float) -> tuple[str, int]:
"""Convert longitude to sign glyph and degree."""
sign_index = int(longitude // 30) % 12
degree = int(longitude % 30)
return SIGN_GLYPHS[sign_index], degree
[docs]
@dataclass
class EphemerisDataPoint:
"""A single data point for one planet at one time."""
date: date
julian_day: float
longitude: float # 0-360° ecliptic longitude
speed: float # degrees per day (negative = retrograde)
harmonic_position: float # Position after harmonic compression
[docs]
@dataclass
class StationPoint:
"""A retrograde or direct station point."""
date: date
julian_day: float
longitude: float
harmonic_position: float
station_type: Literal["retrograde", "direct"]
[docs]
@dataclass
class AspectCrossing:
"""A point where two planet lines cross (aspect in harmonic view)."""
date: date
harmonic_position: float
planet1: str
planet2: str
aspect_type: str # "conjunction", "square", "opposition"
longitude1: float # Actual longitude of planet 1
longitude2: float # Actual longitude of planet 2
is_transit_to_natal: bool = False # True if this is a transit-to-natal aspect
[docs]
@dataclass
class NatalPosition:
"""A natal planet position for overlay on the ephemeris."""
planet: str
longitude: float
harmonic_position: float
[docs]
@dataclass
class GraphicEphemerisConfig:
"""Configuration for graphic ephemeris rendering."""
start_date: date
end_date: date
harmonic: Literal[360, 90, 45] = 90
planets: list[str] = field(default_factory=lambda: DEFAULT_PLANETS.copy())
# Dimensions
width: int = 1400
height: int = 900
# Margins (pixels)
margin_left: int = 90 # Space for Y-axis labels + left glyphs
margin_right: int = 80 # Space for right glyphs + degree info
margin_top: int = 60 # Space for title
margin_bottom: int = 80 # Space for X-axis labels + legend
# Styling
background_color: str = "#FFFFFF"
grid_color: str = "#E8E8E8"
grid_color_major: str = "#D0D0D0"
axis_color: str = "#666666"
text_color: str = "#333333"
line_width: float = 2.5 # Thicker lines
# Features
show_stations: bool = True
show_grid: bool = True
show_title: bool = True
show_legend: bool = True
show_aspects: bool = True # Show aspect markers at line crossings
title: str | None = None # Auto-generated if None
# Data resolution
days_per_point: int = 1 # Calculate position every N days
[docs]
class GraphicEphemeris:
"""
Graphic Ephemeris visualization.
Renders planetary positions over time as a graph, with optional
harmonic compression (90° or 45°) to show hard aspects as conjunctions.
Example:
>>> eph = GraphicEphemeris(
... start_date="2025-01-01",
... end_date="2025-12-31",
... harmonic=90,
... )
>>> eph.draw("ephemeris_2025.svg")
# Include Chiron and North Node
>>> eph = GraphicEphemeris(
... start_date="2025-01-01",
... end_date="2025-12-31",
... planets=EXTENDED_PLANETS,
... )
# With natal chart overlay (shows transit-to-natal aspects)
>>> from stellium import ChartBuilder
>>> natal = ChartBuilder.from_native(my_native).calculate()
>>> eph = GraphicEphemeris(
... start_date="2025-01-01",
... end_date="2025-12-31",
... natal_chart=natal,
... )
>>> eph.draw("transits_2025.svg")
"""
def __init__(
self,
start_date: str | date,
end_date: str | date,
harmonic: Literal[360, 90, 45] = 90,
planets: list[str] | None = None,
natal_chart: "CalculatedChart | None" = None,
natal_planets: list[str] | None = None,
width: int = 1400,
height: int = 900,
show_stations: bool = True,
show_grid: bool = True,
show_legend: bool = True,
show_aspects: bool = True,
title: str | None = None,
):
"""
Initialize a graphic ephemeris.
Args:
start_date: Start date (YYYY-MM-DD string or date object)
end_date: End date (YYYY-MM-DD string or date object)
harmonic: Harmonic compression (360=full, 90=quarter, 45=eighth)
planets: List of planet names to include (default: Sun through Pluto)
Use EXTENDED_PLANETS to include Chiron and North Node
natal_chart: Optional CalculatedChart to overlay natal positions
natal_planets: Which natal planets to show (default: same as planets)
width: SVG width in pixels
height: SVG height in pixels
show_stations: Show retrograde/direct station markers
show_grid: Show background grid lines
show_legend: Show legend explaining station symbols
show_aspects: Show aspect type labels at line crossings (90°/45° only)
If natal_chart is provided, shows transit-to-natal aspects instead
title: Custom title (auto-generated if None)
"""
# Parse dates if strings
if isinstance(start_date, str):
start_date = datetime.strptime(start_date, "%Y-%m-%d").date()
if isinstance(end_date, str):
end_date = datetime.strptime(end_date, "%Y-%m-%d").date()
self.config = GraphicEphemerisConfig(
start_date=start_date,
end_date=end_date,
harmonic=harmonic,
planets=planets if planets is not None else DEFAULT_PLANETS.copy(),
width=width,
height=height,
show_stations=show_stations,
show_grid=show_grid,
show_legend=show_legend,
show_aspects=show_aspects,
title=title,
)
# Natal chart overlay
self._natal_chart = natal_chart
self._natal_planets = natal_planets
self._natal_positions: list[NatalPosition] | None = None
# Data storage (lazy-loaded)
self._data: dict[str, list[EphemerisDataPoint]] | None = None
self._stations: dict[str, list[StationPoint]] | None = None
self._aspects: list[AspectCrossing] | None = None
@property
def plot_width(self) -> int:
"""Width of the plot area (excluding margins)."""
return self.config.width - self.config.margin_left - self.config.margin_right
@property
def plot_height(self) -> int:
"""Height of the plot area (excluding margins)."""
return self.config.height - self.config.margin_top - self.config.margin_bottom
def _date_to_julian_day(self, d: date) -> float:
"""Convert a date to Julian Day number."""
import swisseph as swe
return swe.julday(d.year, d.month, d.day, 12.0) # Noon UTC
def _generate_data(self) -> dict[str, list[EphemerisDataPoint]]:
"""
Calculate planetary positions for all dates in the range.
Returns:
Dictionary mapping planet names to lists of data points.
"""
import swisseph as swe
from stellium.engines.ephemeris import _set_ephemeris_path
_set_ephemeris_path()
data: dict[str, list[EphemerisDataPoint]] = {
planet: [] for planet in self.config.planets
}
# Iterate through date range
current_date = self.config.start_date
while current_date <= self.config.end_date:
jd = self._date_to_julian_day(current_date)
for planet in self.config.planets:
if planet not in PLANET_IDS:
continue
planet_id = PLANET_IDS[planet]
try:
# Calculate position with speed
result = swe.calc_ut(jd, planet_id, swe.FLG_SWIEPH | swe.FLG_SPEED)
longitude = result[0][0]
speed = result[0][3] # degrees per day
# Apply harmonic compression
harmonic_pos = longitude % self.config.harmonic
data[planet].append(
EphemerisDataPoint(
date=current_date,
julian_day=jd,
longitude=longitude,
speed=speed,
harmonic_position=harmonic_pos,
)
)
except Exception:
# Skip points that fail to calculate
pass
current_date += timedelta(days=self.config.days_per_point)
return data
def _detect_stations(
self, data: dict[str, list[EphemerisDataPoint]]
) -> dict[str, list[StationPoint]]:
"""
Detect retrograde and direct station points.
A station occurs when the planet's speed changes sign
(positive to negative = retrograde station, negative to positive = direct).
Args:
data: Ephemeris data from _generate_data()
Returns:
Dictionary mapping planet names to lists of station points.
"""
stations: dict[str, list[StationPoint]] = {
planet: [] for planet in self.config.planets
}
for planet, points in data.items():
# Sun and Moon don't go retrograde
if planet in ("Sun", "Moon"):
continue
for i in range(1, len(points)):
prev_point = points[i - 1]
curr_point = points[i]
# Check for sign change in speed
if prev_point.speed > 0 and curr_point.speed < 0:
# Retrograde station
stations[planet].append(
StationPoint(
date=curr_point.date,
julian_day=curr_point.julian_day,
longitude=curr_point.longitude,
harmonic_position=curr_point.harmonic_position,
station_type="retrograde",
)
)
elif prev_point.speed < 0 and curr_point.speed > 0:
# Direct station
stations[planet].append(
StationPoint(
date=curr_point.date,
julian_day=curr_point.julian_day,
longitude=curr_point.longitude,
harmonic_position=curr_point.harmonic_position,
station_type="direct",
)
)
return stations
def _detect_aspect_crossings(
self, data: dict[str, list[EphemerisDataPoint]]
) -> list[AspectCrossing]:
"""
Detect where planet lines cross (aspects in harmonic view).
In a 90° harmonic:
- Lines crossing = hard aspect (conjunction, square, or opposition)
- Determine which by checking actual longitude difference
Args:
data: Ephemeris data from _generate_data()
Returns:
List of aspect crossings.
"""
if self.config.harmonic == 360:
# Full zodiac - crossings are just conjunctions
return []
aspects: list[AspectCrossing] = []
planets = list(data.keys())
# Check all planet pairs
for i, planet1 in enumerate(planets):
for planet2 in planets[i + 1 :]:
points1 = data[planet1]
points2 = data[planet2]
if not points1 or not points2:
continue
# Find crossings by checking when the difference changes sign
for j in range(1, min(len(points1), len(points2))):
prev_diff = (
points1[j - 1].harmonic_position
- points2[j - 1].harmonic_position
)
curr_diff = (
points1[j].harmonic_position - points2[j].harmonic_position
)
# Handle wrap-around
if abs(prev_diff) > self.config.harmonic / 2:
continue
if abs(curr_diff) > self.config.harmonic / 2:
continue
# Sign change indicates crossing
if prev_diff * curr_diff < 0 and abs(curr_diff) < 10:
# Crossing detected - determine aspect type
lon1 = points1[j].longitude
lon2 = points2[j].longitude
diff = abs(lon1 - lon2)
if diff > 180:
diff = 360 - diff
# Classify aspect
if diff < 15 or diff > 345:
aspect_type = "☌" # Conjunction
elif 75 < diff < 105:
aspect_type = "□" # Square
elif 165 < diff < 195:
aspect_type = "☍" # Opposition
else:
continue # Not a clean hard aspect
aspects.append(
AspectCrossing(
date=points1[j].date,
harmonic_position=points1[j].harmonic_position,
planet1=planet1,
planet2=planet2,
aspect_type=aspect_type,
longitude1=lon1,
longitude2=lon2,
)
)
return aspects
def _extract_natal_positions(self) -> list[NatalPosition]:
"""
Extract natal planet positions from the natal chart.
Returns:
List of NatalPosition objects.
"""
if self._natal_chart is None:
return []
positions = []
# Determine which planets to show from natal chart
natal_planets = self._natal_planets or self.config.planets
for pos in self._natal_chart.positions:
if pos.name in natal_planets:
harmonic_pos = pos.longitude % self.config.harmonic
positions.append(
NatalPosition(
planet=pos.name,
longitude=pos.longitude,
harmonic_position=harmonic_pos,
)
)
return positions
def _detect_transit_to_natal_aspects(
self,
data: dict[str, list[EphemerisDataPoint]],
natal_positions: list[NatalPosition],
) -> list[AspectCrossing]:
"""
Detect when transiting planets cross natal planet positions.
In a 90° harmonic, a transit line crossing a natal horizontal line
indicates a hard aspect (conjunction, square, or opposition).
Args:
data: Transit ephemeris data
natal_positions: Natal planet positions
Returns:
List of transit-to-natal aspect crossings.
"""
if self.config.harmonic == 360:
return []
aspects: list[AspectCrossing] = []
for natal in natal_positions:
natal_y = natal.harmonic_position
for transit_planet, points in data.items():
if not points:
continue
# Find when transit line crosses the natal position
for j in range(1, len(points)):
prev_pos = points[j - 1].harmonic_position
curr_pos = points[j].harmonic_position
# Skip wrap-around points
if abs(curr_pos - prev_pos) > self.config.harmonic / 2:
continue
# Check if the transit crossed the natal line
prev_diff = prev_pos - natal_y
curr_diff = curr_pos - natal_y
# Handle wrap-around for natal position
if abs(prev_diff) > self.config.harmonic / 2:
if prev_diff > 0:
prev_diff -= self.config.harmonic
else:
prev_diff += self.config.harmonic
if abs(curr_diff) > self.config.harmonic / 2:
if curr_diff > 0:
curr_diff -= self.config.harmonic
else:
curr_diff += self.config.harmonic
# Sign change indicates crossing
if prev_diff * curr_diff < 0 and abs(curr_diff) < 10:
# Crossing detected - determine aspect type
transit_lon = points[j].longitude
natal_lon = natal.longitude
diff = abs(transit_lon - natal_lon)
if diff > 180:
diff = 360 - diff
# Classify aspect
if diff < 15 or diff > 345:
aspect_type = "☌" # Conjunction
elif 75 < diff < 105:
aspect_type = "□" # Square
elif 165 < diff < 195:
aspect_type = "☍" # Opposition
else:
continue # Not a clean hard aspect
aspects.append(
AspectCrossing(
date=points[j].date,
harmonic_position=natal_y, # Use natal position for Y
planet1=transit_planet,
planet2=natal.planet,
aspect_type=aspect_type,
longitude1=transit_lon,
longitude2=natal_lon,
is_transit_to_natal=True,
)
)
return aspects
def _date_to_x(self, d: date) -> float:
"""Convert a date to X coordinate in the plot area."""
total_days = (self.config.end_date - self.config.start_date).days
if total_days == 0:
return self.config.margin_left
day_offset = (d - self.config.start_date).days
x_ratio = day_offset / total_days
return self.config.margin_left + (x_ratio * self.plot_width)
def _position_to_y(self, harmonic_position: float) -> float:
"""
Convert a harmonic position to Y coordinate.
Y increases downward in SVG, but we want 0° at bottom.
"""
y_ratio = harmonic_position / self.config.harmonic
# Invert: 0° at bottom, harmonic° at top
return self.config.margin_top + ((1 - y_ratio) * self.plot_height)
def _get_planet_color(self, planet: str) -> str:
"""Get the color for a planet line."""
return get_planet_glyph_color(
planet,
PlanetGlyphPalette.SIGN_RULER,
theme_default_color="#666666",
)
def _draw_grid(self, dwg: svgwrite.Drawing) -> None:
"""Draw the background grid."""
cfg = self.config
# Horizontal grid lines (position)
# For 90° harmonic: lines at 0, 10, 20, 30, ... 90
# Major lines at 0, 30, 60, 90 (sign boundaries)
step = 5 if cfg.harmonic == 90 else (15 if cfg.harmonic == 45 else 30)
major_step = 30 if cfg.harmonic >= 90 else 15
for pos in range(0, cfg.harmonic + 1, step):
y = self._position_to_y(pos)
is_major = pos % major_step == 0
dwg.add(
dwg.line(
start=(cfg.margin_left, y),
end=(cfg.width - cfg.margin_right, y),
stroke=cfg.grid_color_major if is_major else cfg.grid_color,
stroke_width=1.0 if is_major else 0.5,
)
)
# Vertical grid lines (time) - monthly
current = date(cfg.start_date.year, cfg.start_date.month, 1)
while current <= cfg.end_date:
x = self._date_to_x(current)
# Major line at January (year boundary)
is_major = current.month == 1
if x >= cfg.margin_left and x <= cfg.width - cfg.margin_right:
dwg.add(
dwg.line(
start=(x, cfg.margin_top),
end=(x, cfg.height - cfg.margin_bottom),
stroke=cfg.grid_color_major if is_major else cfg.grid_color,
stroke_width=1.0 if is_major else 0.5,
)
)
# Next month
if current.month == 12:
current = date(current.year + 1, 1, 1)
else:
current = date(current.year, current.month + 1, 1)
def _draw_natal_lines(
self,
dwg: svgwrite.Drawing,
natal_positions: list[NatalPosition],
) -> None:
"""Draw horizontal lines for natal planet positions (labels drawn separately)."""
cfg = self.config
for natal in natal_positions:
y = self._position_to_y(natal.harmonic_position)
color = self._get_planet_color(natal.planet)
# Draw horizontal dashed line across the plot
dwg.add(
dwg.line(
start=(cfg.margin_left, y),
end=(cfg.width - cfg.margin_right, y),
stroke=color,
stroke_width=1.5,
stroke_dasharray="6,4", # Dashed line
opacity=0.7,
)
)
def _draw_axes(self, dwg: svgwrite.Drawing) -> None:
"""Draw the axes and labels."""
cfg = self.config
# Y-axis labels (position)
if cfg.harmonic == 90:
# Labels every 5° with modality names at major points
labels = []
for deg in range(0, 91, 5):
if deg == 0 or deg == 90:
labels.append((deg, "0° Cardinal"))
elif deg == 30:
labels.append((deg, "0° Fixed"))
elif deg == 60:
labels.append((deg, "0° Mutable"))
else:
labels.append((deg, f"{deg % 30}°"))
elif cfg.harmonic == 45:
# Labels every 5° for 45° harmonic
labels = []
for deg in range(0, 46, 5):
if deg == 0 or deg == 45:
labels.append((deg, "0°"))
else:
labels.append((deg, f"{deg}°"))
else: # 360
labels = [
(0, "0° ♈"),
(30, "0° ♉"),
(60, "0° ♊"),
(90, "0° ♋"),
(120, "0° ♌"),
(150, "0° ♍"),
(180, "0° ♎"),
(210, "0° ♏"),
(240, "0° ♐"),
(270, "0° ♑"),
(300, "0° ♒"),
(330, "0° ♓"),
(360, "0° ♈"),
]
for pos, label in labels:
y = self._position_to_y(pos)
# Use smaller font for intermediate labels
is_major = (
(cfg.harmonic == 90 and pos % 30 == 0)
or (cfg.harmonic == 45 and pos % 15 == 0)
or cfg.harmonic == 360
)
dwg.add(
dwg.text(
label,
insert=(cfg.margin_left - 15, y + 4),
text_anchor="end",
font_size="11px" if is_major else "9px",
font_family='"Arial", "Helvetica", sans-serif',
fill=cfg.text_color if is_major else "#888888",
)
)
# X-axis labels (time) - months
current = date(cfg.start_date.year, cfg.start_date.month, 1)
month_names = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
]
while current <= cfg.end_date:
x = self._date_to_x(current)
if x >= cfg.margin_left and x <= cfg.width - cfg.margin_right:
# Month label
dwg.add(
dwg.text(
month_names[current.month - 1],
insert=(x, cfg.height - cfg.margin_bottom + 20),
text_anchor="middle",
font_size="10px",
font_family="Arial, sans-serif",
fill=cfg.text_color,
)
)
# Year label (only at January or start)
if current.month == 1 or current == date(
cfg.start_date.year, cfg.start_date.month, 1
):
dwg.add(
dwg.text(
str(current.year),
insert=(x, cfg.height - cfg.margin_bottom + 35),
text_anchor="middle",
font_size="12px",
font_weight="bold",
font_family="Arial, sans-serif",
fill=cfg.text_color,
)
)
# Next month
if current.month == 12:
current = date(current.year + 1, 1, 1)
else:
current = date(current.year, current.month + 1, 1)
def _draw_planet_line(
self,
dwg: svgwrite.Drawing,
planet: str,
points: list[EphemerisDataPoint],
) -> None:
"""
Draw the line for a single planet.
Handles wrap-around at harmonic boundaries by breaking the line.
"""
if not points:
return
color = self._get_planet_color(planet)
cfg = self.config
# Build path segments (break at wrap-around points)
segments: list[list[tuple[float, float]]] = []
current_segment: list[tuple[float, float]] = []
prev_pos = None
for point in points:
x = self._date_to_x(point.date)
y = self._position_to_y(point.harmonic_position)
# Detect wrap-around (position jumps by more than half the harmonic)
if prev_pos is not None:
delta = abs(point.harmonic_position - prev_pos)
if delta > cfg.harmonic / 2:
# Wrap-around detected - start new segment
if current_segment:
segments.append(current_segment)
current_segment = []
current_segment.append((x, y))
prev_pos = point.harmonic_position
if current_segment:
segments.append(current_segment)
# Draw each segment as a path
for segment in segments:
if len(segment) < 2:
continue
# Build SVG path
path_data = f"M {segment[0][0]:.1f} {segment[0][1]:.1f}"
for x, y in segment[1:]:
path_data += f" L {x:.1f} {y:.1f}"
dwg.add(
dwg.path(
d=path_data,
stroke=color,
stroke_width=cfg.line_width,
fill="none",
stroke_linecap="round",
stroke_linejoin="round",
)
)
def _draw_stations(
self,
dwg: svgwrite.Drawing,
planet: str,
stations: list[StationPoint],
) -> None:
"""Draw station markers for a planet."""
if not stations:
return
color = self._get_planet_color(planet)
for station in stations:
x = self._date_to_x(station.date)
y = self._position_to_y(station.harmonic_position)
# Draw smaller circle marker (radius 3 instead of 5)
# Filled for retrograde, hollow for direct
dwg.add(
dwg.circle(
center=(x, y),
r=3,
stroke=color,
stroke_width=1.5,
fill=color if station.station_type == "retrograde" else "none",
)
)
def _draw_aspects(
self,
dwg: svgwrite.Drawing,
aspects: list[AspectCrossing],
) -> None:
"""Draw aspect markers at line crossings."""
for aspect in aspects:
x = self._date_to_x(aspect.date)
y = self._position_to_y(aspect.harmonic_position)
# Draw larger, bolder aspect glyph directly at crossing
dwg.add(
dwg.text(
aspect.aspect_type,
insert=(x, y + 5), # Centered on the crossing
text_anchor="middle",
font_size="14px",
font_weight="bold",
font_family='"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", serif',
fill="#555555",
)
)
def _draw_planet_glyphs(
self,
dwg: svgwrite.Drawing,
data: dict[str, list[EphemerisDataPoint]],
) -> None:
"""
Draw planet glyphs with degree info.
If natal chart is provided: transits on left, natal on right.
Otherwise: transits on both left and right.
"""
from stellium.visualization.core import PLANET_GLYPHS
cfg = self.config
has_natal = self._natal_positions is not None and len(self._natal_positions) > 0
# Collect transit positions for left side (start of year)
left_positions: list[
tuple[float, str, str, float]
] = [] # (y, planet, glyph, longitude)
for planet, points in data.items():
if not points:
continue
glyph = PLANET_GLYPHS.get(planet, planet[:2])
first_point = points[0]
y_left = self._position_to_y(first_point.harmonic_position)
left_positions.append((y_left, planet, glyph, first_point.longitude))
# Collect right side positions
if has_natal:
# Right side shows natal positions
right_positions: list[
tuple[float, str, str, float, bool]
] = [] # (y, planet, glyph, longitude, is_natal)
for natal in self._natal_positions:
glyph = PLANET_GLYPHS.get(natal.planet, natal.planet[:2])
y = self._position_to_y(natal.harmonic_position)
right_positions.append((y, natal.planet, glyph, natal.longitude, True))
else:
# Right side shows transit end positions
right_positions = []
for planet, points in data.items():
if not points:
continue
glyph = PLANET_GLYPHS.get(planet, planet[:2])
last_point = points[-1]
y_right = self._position_to_y(last_point.harmonic_position)
right_positions.append(
(y_right, planet, glyph, last_point.longitude, False)
)
# Sort by Y position and offset overlapping glyphs
min_spacing = 14 # Minimum vertical spacing between glyphs
def offset_overlapping_4(
positions: list[tuple[float, str, str, float]],
) -> list[tuple[float, str, str, float]]:
"""Offset overlapping glyphs vertically (4-tuple version)."""
if not positions:
return positions
sorted_pos = sorted(positions, key=lambda p: p[0])
result = [sorted_pos[0]]
for i in range(1, len(sorted_pos)):
y, planet, glyph, lon = sorted_pos[i]
prev_y = result[-1][0]
if y - prev_y < min_spacing:
y = prev_y + min_spacing
result.append((y, planet, glyph, lon))
return result
def offset_overlapping_5(
positions: list[tuple[float, str, str, float, bool]],
) -> list[tuple[float, str, str, float, bool]]:
"""Offset overlapping glyphs vertically (5-tuple version)."""
if not positions:
return positions
sorted_pos = sorted(positions, key=lambda p: p[0])
result = [sorted_pos[0]]
for i in range(1, len(sorted_pos)):
y, planet, glyph, lon, is_natal = sorted_pos[i]
prev_y = result[-1][0]
if y - prev_y < min_spacing:
y = prev_y + min_spacing
result.append((y, planet, glyph, lon, is_natal))
return result
left_positions = offset_overlapping_4(left_positions)
right_positions = offset_overlapping_5(right_positions)
# Draw left side glyphs (transits at start)
for y, planet, glyph, longitude in left_positions:
color = self._get_planet_color(planet)
sign_glyph, degree = _longitude_to_sign_degree(longitude)
# Degree + sign info (to the left)
dwg.add(
dwg.text(
f"{degree}°{sign_glyph}",
insert=(cfg.margin_left - 45, y + 4),
text_anchor="end",
font_size="10px",
font_family='"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", "Arial", sans-serif',
fill=cfg.text_color,
)
)
# Glyph (closer to graph)
dwg.add(
dwg.text(
glyph,
insert=(cfg.margin_left - 8, y + 5),
text_anchor="end",
font_size="14px",
font_family='"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", serif',
fill=color,
)
)
# Draw right side glyphs
for y, planet, glyph, longitude, is_natal in right_positions:
color = self._get_planet_color(planet)
sign_glyph, degree = _longitude_to_sign_degree(longitude)
# Glyph (closer to graph) - prefix with 'n' if natal
glyph_text = f"n{glyph}" if is_natal else glyph
dwg.add(
dwg.text(
glyph_text,
insert=(cfg.width - cfg.margin_right + 8, y + 5),
text_anchor="start",
font_size="14px" if not is_natal else "12px",
font_family='"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", serif',
fill=color,
)
)
# Degree + sign info (to the right)
x_offset = 28 if not is_natal else 35 # More offset for natal "n☉" prefix
dwg.add(
dwg.text(
f"{degree}°{sign_glyph}",
insert=(cfg.width - cfg.margin_right + x_offset, y + 4),
text_anchor="start",
font_size="10px",
font_family='"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", "Arial", sans-serif',
fill=cfg.text_color,
)
)
def _draw_legend(self, dwg: svgwrite.Drawing) -> None:
"""Draw legend explaining station symbols."""
cfg = self.config
# Position legend at bottom right
legend_x = cfg.width - cfg.margin_right - 120
legend_y = cfg.height - 25
# Retrograde station
dwg.add(
dwg.circle(
center=(legend_x, legend_y),
r=3,
stroke="#666666",
stroke_width=1.5,
fill="#666666",
)
)
dwg.add(
dwg.text(
"Rx Station",
insert=(legend_x + 10, legend_y + 4),
font_size="10px",
font_family="Arial, sans-serif",
fill=cfg.text_color,
)
)
# Direct station
dwg.add(
dwg.circle(
center=(legend_x + 80, legend_y),
r=3,
stroke="#666666",
stroke_width=1.5,
fill="none",
)
)
dwg.add(
dwg.text(
"Direct Station",
insert=(legend_x + 90, legend_y + 4),
font_size="10px",
font_family="Arial, sans-serif",
fill=cfg.text_color,
)
)
def _draw_title(self, dwg: svgwrite.Drawing) -> None:
"""Draw the chart title and natal chart info if present."""
cfg = self.config
if cfg.title:
title = cfg.title
else:
# Auto-generate title
year_start = cfg.start_date.year
year_end = cfg.end_date.year
if year_start == year_end:
year_str = str(year_start)
else:
year_str = f"{year_start}-{year_end}"
if self._natal_chart is not None:
title = f"Transits {year_str} ({cfg.harmonic}° Harmonic)"
else:
title = f"Graphic Ephemeris {year_str} ({cfg.harmonic}° Harmonic)"
# Main title (left-aligned if natal, centered otherwise)
if self._natal_chart is not None:
title_x = cfg.margin_left
title_anchor = "start"
else:
title_x = cfg.width / 2
title_anchor = "middle"
dwg.add(
dwg.text(
title,
insert=(title_x, 30),
text_anchor=title_anchor,
font_size="18px",
font_weight="bold",
font_family="Arial, sans-serif",
fill=cfg.text_color,
)
)
# If natal chart provided, show chart info on right side
if self._natal_chart is not None:
info_x = cfg.width - cfg.margin_right
# Get natal chart info
name = self._natal_chart.metadata.get("name", "")
if name:
dwg.add(
dwg.text(
f"Natal: {name}",
insert=(info_x, 18),
text_anchor="end",
font_size="12px",
font_weight="bold",
font_family="Arial, sans-serif",
fill=cfg.text_color,
)
)
# Format datetime
local_dt = self._natal_chart.datetime.local_datetime
date_str = local_dt.strftime("%b %d, %Y %H:%M")
# Get location
location = self._natal_chart.location
location_str = (
location.name
if location.name
else f"{location.latitude:.2f}, {location.longitude:.2f}"
)
# Draw datetime and location
dwg.add(
dwg.text(
f"{date_str} · {location_str}",
insert=(info_x, 32),
text_anchor="end",
font_size="10px",
font_family="Arial, sans-serif",
fill="#666666",
)
)
[docs]
def draw(self, filename: str | Path = "ephemeris.svg") -> svgwrite.Drawing:
"""
Render the graphic ephemeris to SVG.
Args:
filename: Output filename for the SVG
Returns:
The svgwrite.Drawing object (already saved to disk)
"""
cfg = self.config
# Generate data if needed
if self._data is None:
self._data = self._generate_data()
if self._stations is None and cfg.show_stations:
self._stations = self._detect_stations(self._data)
# Extract natal positions if natal chart provided
if self._natal_positions is None and self._natal_chart is not None:
self._natal_positions = self._extract_natal_positions()
# Detect aspects - either transit-to-transit or transit-to-natal
if self._aspects is None and cfg.show_aspects and cfg.harmonic in (90, 45):
if self._natal_positions:
# Transit-to-natal aspects
self._aspects = self._detect_transit_to_natal_aspects(
self._data, self._natal_positions
)
else:
# Transit-to-transit aspects
self._aspects = self._detect_aspect_crossings(self._data)
# Create SVG drawing
dwg = svgwrite.Drawing(
filename=filename,
size=(f"{cfg.width}px", f"{cfg.height}px"),
viewBox=f"0 0 {cfg.width} {cfg.height}",
)
# Background
dwg.add(
dwg.rect(
insert=(0, 0),
size=(cfg.width, cfg.height),
fill=cfg.background_color,
)
)
# Draw layers
if cfg.show_grid:
self._draw_grid(dwg)
# Draw natal lines (before transit lines so they appear behind)
if self._natal_positions:
self._draw_natal_lines(dwg, self._natal_positions)
self._draw_axes(dwg)
# Draw planet lines
for planet in cfg.planets:
if planet in self._data:
self._draw_planet_line(dwg, planet, self._data[planet])
# Draw stations
if cfg.show_stations and self._stations:
for planet in cfg.planets:
if planet in self._stations:
self._draw_stations(dwg, planet, self._stations[planet])
# Draw aspect markers
if cfg.show_aspects and self._aspects:
self._draw_aspects(dwg, self._aspects)
# Draw glyphs on both sides (only for transits, not natal)
self._draw_planet_glyphs(dwg, self._data)
# Legend
if cfg.show_legend and cfg.show_stations:
self._draw_legend(dwg)
# Title
if cfg.show_title:
self._draw_title(dwg)
# Save
dwg.save()
return dwg