"""
Profection wheel visualization section.
Generates SVG wheel visualizations for annual profections:
- Circular wheel with ages 0-95 spiraling through 12 houses
- House labels with zodiac signs around perimeter
- Natal planet positions marked on the wheel
- Current age highlighting
- Summary table with profection details
"""
from __future__ import annotations
import datetime as dt
import math
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import svgwrite
from stellium.core.models import CalculatedChart
from stellium.core.registry import CELESTIAL_REGISTRY
from ._utils import get_sign_glyph
from .zr_visualization import _add_font_defs
if TYPE_CHECKING:
pass
# =============================================================================
# Constants & Styling
# =============================================================================
# Color palette (matching ZR visualization style)
COLORS = {
"background": "#ffffff",
"current_period": "#f5e6c8", # Warm cream highlight for current age
"wheel_background": "#faf8f5", # Light cream for wheel background
"zodiac_ring": "#e8e4df", # Shaded ring for zodiac signs
"natal_ring": "#e8dff0", # Light purple ring for natal placements
"house_line": "#d0c8c0", # Subtle house division lines
"text_dark": "#2d2330",
"text_muted": "#6b4d6e",
"age_text": "#4a4a4a",
"house_label": "#2d2330",
"legend_box": "#f5e6c8",
"legend_border": "#d0c8c0",
"table_header": "#4a3353",
"table_border": "#d0c8c0",
}
# Wheel dimensions
SVG_WIDTH = 600
SVG_HEIGHT = 800 # Includes table below
WHEEL_SIZE = 520
WHEEL_CENTER = WHEEL_SIZE / 2
WHEEL_MARGIN = 40
# Ring structure:
# - Innermost: house labels (1st, 2nd, etc.)
# - Middle 8 rings: ages 0-95
# - Zodiac ring: shaded ring with zodiac signs and rulers
# - Outermost: natal placements ring (light purple)
NUM_AGE_RINGS = 8
HOUSE_LABEL_RADIUS = 45 # Inner ring for house labels
AGE_INNER_RADIUS = 70 # Where age rings start
AGE_OUTER_RADIUS = 200 # Where age rings end
ZODIAC_RING_INNER = 205 # Shaded zodiac ring
ZODIAC_RING_OUTER = 235
NATAL_RING_INNER = 238 # Light purple natal placements ring
NATAL_RING_OUTER = 260
AGE_RING_WIDTH = (AGE_OUTER_RADIUS - AGE_INNER_RADIUS) / NUM_AGE_RINGS
# Legacy names for compatibility
OUTER_RADIUS = AGE_OUTER_RADIUS
INNER_RADIUS = AGE_INNER_RADIUS
NUM_RINGS = NUM_AGE_RINGS
RING_WIDTH = AGE_RING_WIDTH
# Sign order
SIGNS = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
]
[docs]
@dataclass
class ProfectionVizConfig:
"""Configuration for profection wheel visualization."""
# Display options
current_age: int | None = None
show_wheel: bool = True
show_table: bool = True
compare_ages: list[int] | None = None # For side-by-side comparison in table
# Styling
width: int = SVG_WIDTH
colors: dict = field(default_factory=lambda: COLORS.copy())
[docs]
class ProfectionVisualizationSection:
"""
Profection wheel visualization section.
Generates an SVG visualization showing:
- Circular wheel with ages spiraling through 12 houses
- Zodiac signs and house labels around the perimeter
- Natal planet positions
- Current age highlighted
- Summary table with profection details
Example:
section = ProfectionVisualizationSection(
age=30,
show_table=True
)
data = section.generate_data(chart)
# data["content"] contains SVG string
"""
def __init__(
self,
age: int | None = None,
date: dt.datetime | str | None = None,
compare_ages: list[int] | None = None,
show_wheel: bool = True,
show_table: bool = True,
house_system: str | None = None,
rulership: str = "traditional",
) -> None:
"""
Initialize profection visualization section.
Args:
age: Current age to highlight (if None, calculated from date)
date: Target date for profection (alternative to age)
compare_ages: List of ages to compare in table (default: current and next)
show_wheel: Whether to show the wheel visualization
show_table: Whether to show the summary table
house_system: House system to use (default: Whole Sign)
rulership: Rulership system ("traditional" or "modern")
"""
self.age = age
self.date = date
self.compare_ages = compare_ages
self.show_wheel = show_wheel
self.show_table = show_table
self.house_system = house_system
self.rulership = rulership
@property
def section_name(self) -> str:
if self.age is not None:
return f"Annual Profections (Age {self.age})"
return "Annual Profections"
[docs]
def generate_data(self, chart: CalculatedChart) -> dict[str, Any]:
"""Generate profection visualization data."""
from stellium.engines.profections import ProfectionEngine
# Create engine
try:
engine = ProfectionEngine(chart, self.house_system, self.rulership)
except ValueError as e:
return {
"type": "text",
"content": f"Could not calculate profections: {e}",
}
# Determine current age
if self.date is not None:
if isinstance(self.date, str):
target_date = dt.datetime.fromisoformat(self.date)
else:
target_date = self.date
current_age = engine._calculate_age_at_date(target_date)
elif self.age is not None:
current_age = self.age
else:
# Default to current date
current_age = engine._calculate_age_at_date(dt.datetime.now())
# Build config
config = ProfectionVizConfig(
current_age=current_age,
show_wheel=self.show_wheel,
show_table=self.show_table,
compare_ages=self.compare_ages or [current_age, current_age + 1],
)
# Generate SVG
results = []
if config.show_wheel:
wheel_svg = self._render_wheel(chart, engine, config)
results.append(
(
"Annual Profections Wheel",
{
"type": "svg",
"content": wheel_svg,
"width": WHEEL_SIZE + WHEEL_MARGIN * 2,
"height": WHEEL_SIZE + WHEEL_MARGIN * 2,
},
)
)
if config.show_table:
table_svg = self._render_table(chart, engine, config)
results.append(
(
"Profection Details",
{
"type": "svg",
"content": table_svg,
"width": SVG_WIDTH,
"height": 200,
},
)
)
if len(results) == 1:
return results[0][1]
elif len(results) > 1:
return {
"type": "compound",
"sections": results,
}
else:
return {
"type": "text",
"content": "No visualization requested.",
}
# =========================================================================
# Wheel Rendering
# =========================================================================
def _render_wheel(
self,
chart: CalculatedChart,
engine,
config: ProfectionVizConfig,
) -> str:
"""Render the profection wheel visualization."""
size = WHEEL_SIZE + WHEEL_MARGIN * 2
dwg = svgwrite.Drawing(size=(size, size))
# Embed font for symbol rendering
_add_font_defs(dwg)
# Add background
dwg.add(
dwg.rect(
(0, 0),
(size, size),
fill=config.colors["background"],
)
)
center_x = size / 2
center_y = size / 2
# Get the sign on the 1st house cusp (natal ASC sign)
houses = chart.get_houses(engine.house_system)
asc_sign = houses.get_sign(1)
asc_sign_idx = SIGNS.index(asc_sign)
# Draw wheel background
dwg.add(
dwg.circle(
center=(center_x, center_y),
r=OUTER_RADIUS + 30,
fill=config.colors["wheel_background"],
stroke="none",
)
)
# Draw house sectors and age numbers
self._draw_house_sectors(dwg, center_x, center_y, asc_sign_idx, config)
# Draw age spiral
self._draw_age_spiral(dwg, center_x, center_y, config.current_age, config)
# Draw house labels and zodiac signs
self._draw_house_labels(dwg, center_x, center_y, houses, config)
# Draw natal planet positions
self._draw_natal_planets(dwg, center_x, center_y, chart, houses, config)
# Title
dwg.add(
dwg.text(
"ANNUAL PROFECTIONS",
insert=(center_x, 25),
text_anchor="middle",
font_family="Arial, sans-serif",
font_size="16px",
font_weight="bold",
fill=config.colors["text_dark"],
)
)
# Legend
self._draw_legend(dwg, size - 120, 20, config)
return dwg.tostring()
def _draw_house_sectors(
self,
dwg: svgwrite.Drawing,
cx: float,
cy: float,
asc_sign_idx: int,
config: ProfectionVizConfig,
) -> None:
"""Draw the 12 house sector divisions, inner labels, and outer rings."""
# Draw natal placements ring (outermost, light purple)
dwg.add(
dwg.circle(
center=(cx, cy),
r=NATAL_RING_OUTER,
fill=config.colors["natal_ring"],
stroke=config.colors["house_line"],
stroke_width=1,
)
)
dwg.add(
dwg.circle(
center=(cx, cy),
r=NATAL_RING_INNER,
fill=config.colors["zodiac_ring"],
stroke=config.colors["house_line"],
stroke_width=1,
)
)
# Draw shaded zodiac ring
dwg.add(
dwg.circle(
center=(cx, cy),
r=ZODIAC_RING_OUTER,
fill=config.colors["zodiac_ring"],
stroke=config.colors["house_line"],
stroke_width=1,
)
)
dwg.add(
dwg.circle(
center=(cx, cy),
r=ZODIAC_RING_INNER,
fill=config.colors["wheel_background"],
stroke=config.colors["house_line"],
stroke_width=1,
)
)
# Draw radial lines for house divisions
# House 1 starts at 9 o'clock (180°), progressing counter-clockwise
center_radius = 25 # Where radial lines start
for house in range(12):
# 180° is 9 o'clock, subtract to go counter-clockwise
angle = math.radians(180 - house * 30)
# Lines go from center circle to outer natal ring
x1 = cx + center_radius * math.cos(angle)
y1 = cy + center_radius * math.sin(angle)
x2 = cx + NATAL_RING_OUTER * math.cos(angle)
y2 = cy + NATAL_RING_OUTER * math.sin(angle)
dwg.add(
dwg.line(
(x1, y1),
(x2, y2),
stroke=config.colors["house_line"],
stroke_width=1,
)
)
# Draw center circle to cap off radial lines
dwg.add(
dwg.circle(
center=(cx, cy),
r=center_radius,
fill=config.colors["wheel_background"],
stroke=config.colors["house_line"],
stroke_width=1,
)
)
# Draw concentric ring outlines for age rings
for ring in range(NUM_AGE_RINGS + 1):
radius = AGE_INNER_RADIUS + ring * AGE_RING_WIDTH
dwg.add(
dwg.circle(
center=(cx, cy),
r=radius,
fill="none",
stroke=config.colors["house_line"],
stroke_width=0.5,
)
)
# Draw house labels (1st, 2nd, etc.) in the innermost area
for house_num in range(1, 13):
house_sector = house_num - 1
# Center of each sector
angle_deg = 180 - (house_sector * 30 + 15)
angle = math.radians(angle_deg)
x = cx + HOUSE_LABEL_RADIUS * math.cos(angle)
y = cy + HOUSE_LABEL_RADIUS * math.sin(angle)
ordinal = self._get_ordinal(house_num)
dwg.add(
dwg.text(
ordinal,
insert=(x, y + 4),
text_anchor="middle",
font_family="Arial, sans-serif",
font_size="9px",
font_weight="bold",
fill=config.colors["text_muted"],
)
)
def _draw_age_spiral(
self,
dwg: svgwrite.Drawing,
cx: float,
cy: float,
current_age: int | None,
config: ProfectionVizConfig,
) -> None:
"""Draw age numbers in spiral pattern through houses."""
for age in range(96): # Ages 0-95
# Determine which ring (cycle) this age is in
ring = age // 12 # 0, 1, 2, 3, 4, 5, 6, 7
if ring >= NUM_RINGS:
break
# Determine which house/sector (0-11)
house_sector = age % 12
# Calculate position
# House 1 starts at 9 o'clock (180°), progressing counter-clockwise
# Offset by 15 degrees to center in sector
angle_deg = 180 - (house_sector * 30 + 15)
angle = math.radians(angle_deg)
# Radius: inner rings are closer to center
radius = INNER_RADIUS + (ring + 0.5) * RING_WIDTH
x = cx + radius * math.cos(angle)
y = cy + radius * math.sin(angle)
# Highlight current age
is_current = current_age is not None and age == current_age
if is_current:
# Draw highlight circle behind the number
dwg.add(
dwg.circle(
center=(x, y),
r=12,
fill=config.colors["current_period"],
stroke=config.colors["text_dark"],
stroke_width=1,
)
)
# Draw age number
font_size = "9px" if ring < 4 else "8px"
font_weight = "bold" if is_current else "normal"
dwg.add(
dwg.text(
str(age),
insert=(x, y + 3),
text_anchor="middle",
font_family="Arial, sans-serif",
font_size=font_size,
font_weight=font_weight,
fill=config.colors["text_dark"]
if is_current
else config.colors["age_text"],
)
)
def _draw_house_labels(
self,
dwg: svgwrite.Drawing,
cx: float,
cy: float,
houses,
config: ProfectionVizConfig,
) -> None:
"""Draw zodiac signs and their ruling planets in the shaded ring."""
from stellium.engines.dignities import DIGNITIES
# Zodiac glyphs go in the center of the shaded outer ring
zodiac_radius = (ZODIAC_RING_INNER + ZODIAC_RING_OUTER) / 2
for house_num in range(1, 13):
# Calculate angle for this house (centered in sector)
# House 1 starts at 9 o'clock (180°), progressing counter-clockwise
house_sector = house_num - 1
angle_deg = 180 - (house_sector * 30 + 15)
angle = math.radians(angle_deg)
x = cx + zodiac_radius * math.cos(angle)
y = cy + zodiac_radius * math.sin(angle)
# Get zodiac sign for this house
sign = houses.get_sign(house_num)
sign_glyph = get_sign_glyph(sign)
# Get traditional ruler for this sign
ruler = DIGNITIES[sign]["traditional"]["ruler"]
ruler_glyph = ""
if ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[ruler].glyph
# Draw sign glyph (slightly above center)
dwg.add(
dwg.text(
sign_glyph,
insert=(x, y - 1),
text_anchor="middle",
font_family="Noto Sans Symbols2, Arial",
font_size="14px",
fill=config.colors["text_dark"],
)
)
# Draw ruler glyph (below sign glyph)
dwg.add(
dwg.text(
ruler_glyph,
insert=(x, y + 12),
text_anchor="middle",
font_family="Noto Sans Symbols2, Arial",
font_size="10px",
fill=config.colors["text_muted"],
)
)
def _draw_natal_planets(
self,
dwg: svgwrite.Drawing,
cx: float,
cy: float,
chart: CalculatedChart,
houses,
config: ProfectionVizConfig,
) -> None:
"""Draw natal planet glyphs in their sign positions in the natal ring."""
# Planets go in the center of the natal ring
planet_radius = (NATAL_RING_INNER + NATAL_RING_OUTER) / 2
# Group planets by sign to handle conjunctions
planets_by_sign: dict[str, list[str]] = {}
for pos in chart.get_planets():
sign = pos.sign
if sign not in planets_by_sign:
planets_by_sign[sign] = []
planets_by_sign[sign].append(pos.name)
# Also add ASC, MC if available
for point_name in ["ASC", "MC"]:
point = chart.get_object(point_name)
if point:
sign = point.sign
if sign not in planets_by_sign:
planets_by_sign[sign] = []
planets_by_sign[sign].append(point_name)
# Draw each planet
for sign, planet_names in planets_by_sign.items():
# Find which house this sign is on
house_num = None
for h in range(1, 13):
if houses.get_sign(h) == sign:
house_num = h
break
if house_num is None:
continue
# Calculate base angle for this house
# House 1 at 9 o'clock (180°), counter-clockwise, centered in sector
house_sector = house_num - 1
base_angle_deg = 180 - (house_sector * 30 + 15)
# Spread multiple planets within the sector
num_planets = len(planet_names)
for i, planet_name in enumerate(planet_names):
# Offset within sector
if num_planets == 1:
offset = 0
else:
offset = (i - (num_planets - 1) / 2) * 8
angle = math.radians(base_angle_deg + offset)
x = cx + planet_radius * math.cos(angle)
y = cy + planet_radius * math.sin(angle)
# Get planet glyph
if planet_name in CELESTIAL_REGISTRY:
glyph = CELESTIAL_REGISTRY[planet_name].glyph
else:
glyph = planet_name[:2]
dwg.add(
dwg.text(
glyph,
insert=(x, y + 4),
text_anchor="middle",
font_family="Noto Sans Symbols2, Arial",
font_size="12px",
fill=config.colors["text_dark"],
)
)
def _draw_legend(
self,
dwg: svgwrite.Drawing,
x: float,
y: float,
config: ProfectionVizConfig,
) -> None:
"""Draw the legend showing current period and natal placements indicators."""
# Legend box (taller to fit both entries)
dwg.add(
dwg.rect(
(x, y),
(115, 45),
fill="white",
stroke=config.colors["legend_border"],
stroke_width=1,
rx=3,
ry=3,
)
)
# Current period indicator
dwg.add(
dwg.rect(
(x + 8, y + 7),
(12, 12),
fill=config.colors["current_period"],
stroke=config.colors["text_dark"],
stroke_width=1,
)
)
dwg.add(
dwg.text(
"Current period",
insert=(x + 26, y + 16),
font_family="Arial, sans-serif",
font_size="9px",
fill=config.colors["text_dark"],
)
)
# Natal placements indicator
dwg.add(
dwg.rect(
(x + 8, y + 26),
(12, 12),
fill=config.colors["natal_ring"],
stroke=config.colors["text_dark"],
stroke_width=1,
)
)
dwg.add(
dwg.text(
"Natal placements",
insert=(x + 26, y + 35),
font_family="Arial, sans-serif",
font_size="9px",
fill=config.colors["text_dark"],
)
)
def _get_ordinal(self, n: int) -> str:
"""Get ordinal string for a number (1st, 2nd, 3rd, etc.)."""
if 11 <= n <= 13:
return f"{n}th"
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
return f"{n}{suffix}"
# =========================================================================
# Table Rendering
# =========================================================================
def _render_table(
self,
chart: CalculatedChart,
engine,
config: ProfectionVizConfig,
) -> str:
"""Render the profection details table."""
width = SVG_WIDTH
height = 200
dwg = svgwrite.Drawing(size=(width, height))
# Embed font
_add_font_defs(dwg)
# Add background
dwg.add(
dwg.rect(
(0, 0),
(width, height),
fill=config.colors["background"],
)
)
# Get ages to compare
ages = config.compare_ages or [config.current_age, config.current_age + 1]
# Table layout
row_labels = [
"SOLAR RETURN\n(NATAL LOCATION)",
"PROFECTED HOUSE",
"ANNUAL TIMELORD",
"NATAL PLACEMENTS IN\nPROFECTED HOUSE",
"TIMELORD'S NATAL POSITION",
"TIMELORD ALSO RULES",
]
label_width = 180
col_width = (width - label_width) / len(ages)
row_height = 25
start_x = 10
start_y = 10
# Draw header row (ages)
for i, age in enumerate(ages):
x = start_x + label_width + i * col_width
dwg.add(
dwg.rect(
(x, start_y),
(col_width - 2, row_height),
fill=config.colors["table_header"],
rx=2,
ry=2,
)
)
dwg.add(
dwg.text(
f"AGE {age}",
insert=(x + col_width / 2, start_y + 17),
text_anchor="middle",
font_family="Arial, sans-serif",
font_size="11px",
font_weight="bold",
fill="white",
)
)
start_y += row_height + 5
# Draw data rows
for row_idx, label in enumerate(row_labels):
y = start_y + row_idx * row_height
# Draw row label
# Handle multi-line labels
label_lines = label.split("\n")
for line_idx, line in enumerate(label_lines):
line_y = y + 15 + (line_idx - len(label_lines) / 2 + 0.5) * 10
dwg.add(
dwg.text(
line,
insert=(start_x, line_y),
font_family="Arial, sans-serif",
font_size="8px",
fill=config.colors["text_muted"],
)
)
# Draw data cells for each age
for i, age in enumerate(ages):
x = start_x + label_width + i * col_width
# Get profection data for this age
profection = engine.annual(age)
cell_value = self._get_table_cell_value(
row_idx, age, profection, chart, engine
)
# Use Arial for text-heavy rows (solar return), symbol font for others
if row_idx == 0: # Solar return - text only
font_family = "Arial, sans-serif"
font_size = "9px"
else:
font_family = "Noto Sans Symbols2, Arial"
font_size = "11px"
dwg.add(
dwg.text(
cell_value,
insert=(x + col_width / 2, y + 16),
text_anchor="middle",
font_family=font_family,
font_size=font_size,
fill=config.colors["text_dark"],
)
)
# Draw row separator
if row_idx < len(row_labels) - 1:
dwg.add(
dwg.line(
(start_x, y + row_height),
(width - 10, y + row_height),
stroke=config.colors["table_border"],
stroke_width=0.5,
)
)
return dwg.tostring()
def _get_table_cell_value(
self,
row_idx: int,
age: int,
profection,
chart: CalculatedChart,
engine,
) -> str:
"""Get the value for a specific table cell."""
if row_idx == 0:
# Solar return date
# Calculate solar return for this age
try:
sr_date = self._calculate_solar_return_date(chart, age)
return sr_date.strftime("%d %b %Y, %I:%M%p")
except Exception:
return "—"
elif row_idx == 1:
# Profected house
sign_glyph = get_sign_glyph(profection.profected_sign)
return f"{sign_glyph} {profection.profected_house}"
elif row_idx == 2:
# Annual timelord
ruler_glyph = ""
if profection.ruler in CELESTIAL_REGISTRY:
ruler_glyph = CELESTIAL_REGISTRY[profection.ruler].glyph
return ruler_glyph
elif row_idx == 3:
# Natal placements in profected house
if profection.planets_in_house:
glyphs = []
for planet in profection.planets_in_house:
if planet.name in CELESTIAL_REGISTRY:
glyphs.append(CELESTIAL_REGISTRY[planet.name].glyph)
return " ".join(glyphs) if glyphs else "—"
return "—"
elif row_idx == 4:
# Timelord's natal position
if profection.ruler_position:
sign_glyph = get_sign_glyph(profection.ruler_position.sign)
house = profection.ruler_house or "?"
return f"{sign_glyph} {house}"
return "—"
elif row_idx == 5:
# Timelord also rules (other signs ruled by this planet)
other_signs = self._get_other_ruled_signs(
profection.ruler, profection.profected_sign
)
if other_signs:
glyphs_houses = []
houses = chart.get_houses(engine.house_system)
for sign in other_signs:
sign_glyph = get_sign_glyph(sign)
# Find which house this sign is on
for h in range(1, 13):
if houses.get_sign(h) == sign:
glyphs_houses.append(f"{sign_glyph} {h}")
break
return " ".join(glyphs_houses) if glyphs_houses else "—"
return "—"
return "—"
def _calculate_solar_return_date(
self, chart: CalculatedChart, age: int
) -> dt.datetime:
"""Calculate the solar return date for a given age."""
from stellium.utils.planetary_crossing import find_nth_return
natal_sun = chart.get_object("Sun")
if natal_sun is None:
raise ValueError("No Sun in chart")
birth_jd = chart.datetime.julian_day
if age == 0:
# Return birth date for age 0
return chart.datetime.utc_datetime
# Find the nth solar return
sr_jd = find_nth_return("Sun", natal_sun.longitude, birth_jd, n=age)
# Convert JD to datetime
from stellium.utils.time import julian_day_to_datetime
return julian_day_to_datetime(sr_jd)
def _get_other_ruled_signs(self, ruler: str, current_sign: str) -> list[str]:
"""Get other signs ruled by the same planet."""
from stellium.engines.dignities import DIGNITIES
other_signs = []
for sign, dignity in DIGNITIES.items():
if sign != current_sign:
if dignity["traditional"]["ruler"] == ruler:
other_signs.append(sign)
return other_signs