"""
Core Chart Drawing Engine (stellium.visualization.core)
This module provides the core, refactored drawing system.
It is based on a "Layer" strategy pattern.
- ChartRenderer: The main "canvas" and coordinate system.
- IRenderLayer: The protocol (interface) that all drawable layers must follow.
"""
import math
from typing import Any, Protocol
import svgwrite
from stellium.core.models import CalculatedChart
from stellium.core.registry import (
ASPECT_REGISTRY,
get_aspect_by_alias,
get_aspect_info,
get_object_info,
)
# Legacy glyph dictionaries - kept for backwards compatibility
# Prefer using the registry via get_glyph() helper function
PLANET_GLYPHS = {
# === Traditional Planets (The Septenary) ===
"Sun": "☉",
"Moon": "☽",
"Mercury": "☿",
"Venus": "♀",
"Mars": "♂",
"Jupiter": "♃",
"Saturn": "♄",
# === Modern Planets ===
"Uranus": "♅",
"Neptune": "♆",
"Pluto": "♇",
# === Chart Points & Nodes ===
"Earth": "♁",
"True Node": "☊", # Also called the North Node
"South Node": "☋",
"Black Moon Lilith": "⚸",
"Part of Fortune": "⊗", # A common glyph, U+2297
# === Asteroids (The "Big Four") ===
"Ceres": "⚳",
"Pallas": "⚴",
"Juno": "⚵",
"Vesta": "⚶",
# === Centaurs ===
"Chiron": "⚷",
"Pholus": "⬰", # (U+2B30) This is the correct glyph
# === Uranian / Witte School Planets ===
# These are very niche, but have standard glyphs
"Cupido": "Cup", # (U+2BD3)
"Hades": "Had", # (U+2BD4)
"Zeus": "Zeu", # (U+2BD5)
"Kronos": "Kro", # (U+2BD6)
"Apollon": "Apo", # (U+2BD7)
"Admetos": "Adm", # (U+2BD8)
"Vulcanus": "Vul", # (U+2BD9)
"Poseidon": "Pos", # (U+2BDA)
}
ZODIAC_GLYPHS = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
ANGLE_GLYPHS = {
"ASC": "Asc",
"MC": "MC",
"DSC": "Dsc",
"IC": "IC",
"Vertex": "Vx",
}
[docs]
def get_glyph(object_name: str) -> dict[str, str]:
"""
Get the glyph for a celestial object, with registry lookup and fallback.
Args:
object_name: Name of the object (e.g., "Sun", "Mean Apogee", "ASC")
Returns:
Dictionary with:
- "type": "unicode" or "svg"
- "value": glyph string (unicode) or SVG content string (for inline embedding)
"""
from pathlib import Path
# Try registry first
obj_info = get_object_info(object_name)
if obj_info:
# Check if there's an SVG path
if obj_info.glyph_svg_path:
# Resolve to absolute path for SVG reading
# The path is relative to project root
svg_path = Path(obj_info.glyph_svg_path)
if not svg_path.is_absolute():
# Go up from visualization/core.py to project root
# visualization/ -> stellium/ -> src/ -> project_root/
project_root = Path(__file__).parent.parent.parent.parent
svg_path = project_root / obj_info.glyph_svg_path
if svg_path.exists():
# Read SVG content for inline embedding
svg_content = svg_path.read_text()
return {"type": "svg", "value": svg_content}
# Fall back to unicode glyph if SVG doesn't exist
return {"type": "unicode", "value": obj_info.glyph}
return {"type": "unicode", "value": obj_info.glyph}
# Fall back to legacy dictionaries (always unicode)
if object_name in PLANET_GLYPHS:
return {"type": "unicode", "value": PLANET_GLYPHS[object_name]}
if object_name in ANGLE_GLYPHS:
return {"type": "unicode", "value": ANGLE_GLYPHS[object_name]}
# Final fallback: use first 2-3 characters
return {"type": "unicode", "value": object_name[:3]}
[docs]
def embed_svg_glyph(
dwg: svgwrite.Drawing,
svg_content: str,
x: float,
y: float,
size: float,
fill_color: str | None = None,
) -> None:
"""
Embed an SVG glyph inline as a nested SVG element.
This function parses SVG content and embeds it directly into the drawing
as a nested <svg> element with proper positioning and scaling. This approach
works across all browsers and SVG viewers, unlike external image references.
Args:
dwg: The svgwrite Drawing to add the element to
svg_content: The raw SVG content string (from get_glyph())
x: Center x coordinate for the glyph
y: Center y coordinate for the glyph
size: Desired size (width and height) in pixels
fill_color: Optional color to override stroke/fill (for theming)
"""
import re
from svgwrite.path import Path as SvgPath
# Extract viewBox from the SVG content using regex
viewbox_match = re.search(r'viewBox="([^"]+)"', svg_content)
viewbox = viewbox_match.group(1) if viewbox_match else "0 0 16 16"
# Calculate position (center the glyph)
svg_x = x - size / 2
svg_y = y - size / 2
# Create nested SVG element with proper positioning
nested_svg = dwg.svg(
insert=(svg_x, svg_y),
size=(size, size),
viewBox=viewbox,
)
# Extract path data using regex (handles namespaced SVGs)
# Find all path elements
path_elements = re.findall(r"<path[^>]+/>", svg_content)
for path_elem in path_elements:
# Extract d attribute
d_match = re.search(r'd="([^"]+)"', path_elem)
path_d = d_match.group(1) if d_match else ""
# Extract style attribute
style_match = re.search(r'style="([^"]+)"', path_elem)
style = style_match.group(1) if style_match else ""
if not path_d:
continue
# Parse style into attributes
stroke = fill_color or "#000"
stroke_width = 0.6
fill = "none"
if "stroke-width:" in style:
sw_match = re.search(r"stroke-width:([^;]+)", style)
if sw_match:
try:
stroke_width = float(sw_match.group(1).strip())
except ValueError:
pass
if "fill:" in style:
fill_match = re.search(r"fill:([^;]+)", style)
if fill_match:
fill = fill_match.group(1).strip()
# Create the path using svgwrite with debug mode disabled
# to bypass the strict path validation
path = SvgPath(
d=path_d,
fill=fill,
stroke=stroke,
stroke_width=stroke_width,
debug=False,
)
path["stroke-linecap"] = "round"
path["stroke-linejoin"] = "round"
nested_svg.add(path)
dwg.add(nested_svg)
[docs]
def get_display_name(object_name: str) -> str:
"""
Get the display name for a celestial object.
Args:
object_name: Technical name (e.g., "Mean Apogee")
Returns:
Display name (e.g., "Black Moon Lilith") or original name if not in registry
"""
obj_info = get_object_info(object_name)
if obj_info:
return obj_info.display_name
return object_name
[docs]
def get_aspect_glyph(aspect_name: str) -> str:
"""
Get the glyph for an astrological aspect.
Args:
aspect_name: Aspect name (e.g., "Conjunction", "Trine", "Conjunct")
Returns:
Unicode glyph string or abbreviation if not found
"""
# Try exact name first
aspect_info = get_aspect_info(aspect_name)
if aspect_info and aspect_info.glyph:
return aspect_info.glyph
# Try as alias (e.g., "Conjunct" → "Conjunction")
aspect_info = get_aspect_by_alias(aspect_name)
if aspect_info and aspect_info.glyph:
return aspect_info.glyph
# Fallback: use first 3 characters
return aspect_name[:3]
[docs]
class ChartRenderer:
"""
The core chart drawing canvas and coordinate system.
This class holds the SVG drawing object and provides the geometric
utilities for layers to draw themselves. It acts as the "Context"
in the strategy pattern.
"""
def __init__(
self,
size: int = 600,
rotation: float = 0.0,
theme: str | None = None,
style_config: dict[str, Any] | None = None,
zodiac_palette: str | None = None,
aspect_palette: str | None = None,
planet_glyph_palette: str | None = None,
color_sign_info: bool = False,
) -> None:
"""
Initialize the chart renderer.
Args:
size: The canvas size in pixels.
rotation: The astrological longitude (in degrees) to fix
at the 9 o'clock position. Defaults to 0 (Aries).
theme: Optional theme name (e.g., "dark", "midnight", "neon").
If provided, loads theme styling. Can still be overridden by style_config.
style_config: Optional style overrides.
zodiac_palette: Optional zodiac palette override (e.g., "viridis", "rainbow").
aspect_palette: Optional aspect palette override (e.g., "plasma", "blues").
planet_glyph_palette: Optional planet glyph palette override (e.g., "element", "chakra").
color_sign_info: If True, color sign glyphs in info stack based on zodiac palette.
"""
self.size = size
self.center = size // 2
self.rotation = rotation
# Initialize offsets (set by extended canvas mode in drawing.py)
self.x_offset = 0
self.y_offset = 0
# Store palette configurations
self.zodiac_palette = zodiac_palette
self.aspect_palette = aspect_palette
self.planet_glyph_palette = planet_glyph_palette
self.color_sign_info = color_sign_info
# Legacy fallback radii for old drawing.py system
# NOTE: In new config-driven system, these get overwritten by LayoutEngine
# which uses ChartWheelConfig.single_radii or .biwheel_radii
self.radii = {
"outer_border": size * 0.48,
"zodiac_ring_outer": size * 0.47,
"zodiac_glyph": size * 0.42,
"zodiac_ring_inner": size * 0.37,
"planet_ring": size * 0.32,
"house_number_ring": size * 0.20,
"aspect_ring_inner": size * 0.18,
# Obsolete synastry keys (unused in new system)
"synastry_planet_ring_inner": size * 0.25,
"synastry_planet_ring_outer": size * 0.35,
}
# Load theme if specified, otherwise use default
if theme:
from .themes import (
ChartTheme,
get_theme_default_aspect_palette,
get_theme_default_palette,
get_theme_default_planet_palette,
get_theme_style,
)
theme_enum = ChartTheme(theme) if isinstance(theme, str) else theme
self.style = get_theme_style(theme_enum)
# Set default palettes from theme if not explicitly provided
if self.zodiac_palette is None:
# None means "use theme's colorful default"
self.zodiac_palette = get_theme_default_palette(theme_enum).value
elif self.zodiac_palette == "monochrome":
# Keep as "monochrome" - ZodiacLayer will use theme's ring_color
pass
# Otherwise use the specific palette name provided
if self.aspect_palette is None:
self.aspect_palette = get_theme_default_aspect_palette(theme_enum).value
if self.planet_glyph_palette is None:
self.planet_glyph_palette = get_theme_default_planet_palette(
theme_enum
).value
else:
self.style = self._get_default_style()
# Set default palettes if not explicitly provided
if self.zodiac_palette is None:
self.zodiac_palette = "grey"
elif self.zodiac_palette == "monochrome":
# For no-theme case, monochrome uses grey
self.zodiac_palette = "grey"
if self.aspect_palette is None:
self.aspect_palette = "classic"
if self.planet_glyph_palette is None:
self.planet_glyph_palette = "default"
# Apply style overrides
if style_config:
# Deep merge dictionaries
for key, value in style_config.items():
if isinstance(value, dict):
self.style[key].update(value)
else:
self.style[key] = value
def _get_default_style(self) -> dict[str, Any]:
"""Provides the base styling configuration."""
return {
"background_color": "#FFFFFF",
"border_color": "#999999",
"border_width": 1,
"font_family_glyphs": '"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", serif',
"font_family_text": '"Arial", "Helvetica", sans-serif',
"zodiac": {
"ring_color": "#EEEEEE",
"line_color": "#BBBBBB",
"glyph_color": "#555555",
"glyph_size": "20px",
},
"houses": {
"line_color": "#CCCCCC",
"line_width": 0.8,
"number_color": "#AAAAAA",
"number_size": "11px",
"fill_alternate": True,
"fill_color_1": "#F5F5F5",
"fill_color_2": "#FFFFFF",
},
"angles": {
"line_color": "#555555",
"line_width": 2.5,
"glyph_color": "#333333",
"glyph_size": "12px",
},
"outer_wheel_angles": {
"line_color": "#888888", # Lighter than inner angles
"line_width": 1.8, # Thinner than inner angles
"glyph_color": "#666666", # Lighter glyph
"glyph_size": "11px", # Slightly smaller
},
"planets": {
"glyph_color": "#222222",
"glyph_size": "32px",
"info_color": "#444444",
"info_size": "10px",
"retro_color": "#E74C3C",
},
"aspects": {
**{
aspect_info.name: {
"color": aspect_info.color,
"width": aspect_info.metadata.get("line_width", 1.5),
"dash": aspect_info.metadata.get("dash_pattern", "1,0"),
}
for aspect_info in ASPECT_REGISTRY.values()
if aspect_info.category
in ["Major", "Minor"] # Only visualize major/minor
},
"default": {"color": "#BDC3C7", "width": 0.5, "dash": "2,2"},
"line_color": "#BBBBBB",
"background_color": "#FFFFFF",
},
}
[docs]
def astrological_to_svg_angle(self, astro_deg: float) -> float:
"""
Converts astrological degrees (0° = Aries) to SVG degrees
(0° = 3 o'clock), appling the chart's rotation.
Our system: 0° Aries is at 9 o'clock (180° SVG).
Rotation is COUNTER-CLOCKWISE.
"""
# Get the degree relative to the rotation point
# if Sun is 15 Leo (135) and Asc (rotation) is 15 Cancer (105)
# then relative degree is 30
relative_deg = (astro_deg - self.rotation + 360) % 360
# Apply the standard formula to the relative degree
# (180 - relative degree) places 0 deg at 9 o clock (180)
# and makes the chart rotate counter-clockwise
svg_angle = (180 + relative_deg - 360) % 360
return svg_angle
[docs]
def polar_to_cartesian(
self, astro_deg: float, radius: float
) -> tuple[float, float]:
"""
Converts an astrological degree (0 degrees Aries) and radius to an (x,y) coordinate.
Accounts for extended canvas offsets when present.
"""
svg_angle_rad = math.radians(self.astrological_to_svg_angle(astro_deg))
# SVG Y is inverted (positive is down)
# Add offsets for extended canvas positioning
x = self.x_offset + self.center + radius * math.cos(svg_angle_rad)
y = self.y_offset + self.center - radius * math.sin(svg_angle_rad)
return x, y
# NOTE: create_svg_drawing() was removed as it was unused legacy code.
# All SVG rendering now goes through ChartComposer and the layer system.
# Background, borders, and all chart elements are rendered as layers.
[docs]
class IRenderLayer(Protocol):
"""
Protocol (interface) for all drawable chart layers.
Each layer is a self-contained drawing strategy.
"""
[docs]
def render(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart: CalculatedChart
) -> None:
"""
The main drawing method for the layer.
Args:
renderer: ChartRenderer instance, used to access coordinate methods
(.polar_to_cartesian) and style/radius definitions.
dwg: The svgwrite.Drawing object to add elements to.
chart: The full CalculatedChart data object.
"""
...