"""
Extended canvas layers for position tables and aspectarian grids.
These layers render tabular data outside the main chart wheel,
requiring an extended canvas with additional space.
"""
from typing import Any
import svgwrite
from stellium.core.chart_utils import is_comparison, is_multichart
from stellium.core.models import Aspect, CalculatedChart, ObjectType
from stellium.core.registry import CELESTIAL_REGISTRY, get_aspect_info
from .core import ChartRenderer, get_glyph
# Legacy aliases for backward compatibility within this module
# These are used by layers.py which imports them from here
_is_comparison = is_comparison
_is_multichart = is_multichart
def _filter_objects_for_tables(positions, object_types=None):
"""
Filter positions to include in position tables and aspectarian.
Default includes:
- All PLANET objects (except Earth)
- All ASTEROID objects
- All POINT objects
- North Node only (exclude South Node)
- ASC/AC and MC only (exclude DSC/DC and IC)
Default excludes:
- MIDPOINT, ARABIC_PART, FIXED_STAR
Args:
positions: List of CelestialPosition objects
object_types: Optional list of ObjectType enum values or strings to include.
If None, uses default filter (planet, asteroid, point, node, angle).
Examples: ["planet", "asteroid", "midpoint"]
[ObjectType.PLANET, ObjectType.ASTEROID]
Returns:
Filtered list of CelestialPosition objects
"""
# Convert object_types to a set of ObjectType enums for fast lookup
if object_types is None:
# Default: include planet, asteroid, point, node, angle
included_types = {
ObjectType.PLANET,
ObjectType.ASTEROID,
ObjectType.POINT,
ObjectType.NODE,
ObjectType.ANGLE,
}
else:
# Convert strings to ObjectType enums
included_types = set()
for obj_type in object_types:
if isinstance(obj_type, str):
# Convert string to ObjectType enum
try:
included_types.add(ObjectType(obj_type.lower()))
except ValueError:
# Skip invalid type names
pass
elif isinstance(obj_type, ObjectType):
included_types.add(obj_type)
filtered = []
for p in positions:
# Skip Earth
if p.name == "Earth":
continue
# Check if object type is in included types
if p.object_type not in included_types:
continue
# For planets: include all except Earth (already checked)
if p.object_type == ObjectType.PLANET:
filtered.append(p)
continue
# For asteroids: include all
if p.object_type == ObjectType.ASTEROID:
filtered.append(p)
continue
# For nodes: include North Node only (exclude South Node)
if p.object_type == ObjectType.NODE:
if p.name in ("North Node", "True Node", "Mean Node"):
filtered.append(p)
continue
# For points: include all
if p.object_type == ObjectType.POINT:
filtered.append(p)
continue
# For angles: include only ASC/AC and MC (exclude DSC/DC and IC)
if p.object_type == ObjectType.ANGLE:
if p.name in ("ASC", "AC", "Ascendant", "MC", "Midheaven"):
filtered.append(p)
continue
# For midpoints and arabic parts: include all if type is in included_types
if p.object_type in (
ObjectType.MIDPOINT,
ObjectType.ARABIC_PART,
ObjectType.FIXED_STAR,
):
filtered.append(p)
continue
return filtered
[docs]
class PositionTableLayer:
"""
Renders a table of planetary positions.
Shows planet name, sign, degree, house, and speed in a tabular format.
Respects chart theme colors.
"""
DEFAULT_STYLE = {
"text_color": "#333333",
"header_color": "#222222",
"text_size": "10px",
"header_size": "11px",
"line_height": 16,
"col_spacing": 55, # Pixels between columns (reduced from 70 for tighter spacing)
"font_weight": "normal",
"header_weight": "bold",
"show_speed": True,
"show_house": True,
}
def __init__(
self,
x_offset: float = 0,
y_offset: float = 0,
style_override: dict[str, Any] | None = None,
object_types: list[str | ObjectType] | None = None,
config: Any | None = None,
) -> None:
"""
Initialize position table layer.
Args:
x_offset: X position offset from canvas origin
y_offset: Y position offset from canvas origin
style_override: Optional style overrides
object_types: Optional list of object types to include.
If None, uses default (planet, asteroid, point, node, angle).
Examples: ["planet", "asteroid", "midpoint"]
config: Optional ChartVisualizationConfig for column widths, padding, etc.
"""
self.x_offset = x_offset
self.y_offset = y_offset
self.style = {**self.DEFAULT_STYLE, **(style_override or {})}
self.object_types = object_types
self.config = config
[docs]
def render(self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart) -> None:
"""Render position table.
Handles CalculatedChart, Comparison, and MultiChart objects.
For Comparison/MultiChart, displays two separate side-by-side tables.
"""
# Check if this is a Comparison or MultiChart object
if _is_comparison(chart):
# Render two separate tables side by side
self._render_comparison_tables(renderer, dwg, chart)
elif _is_multichart(chart):
# MultiChart uses same rendering as comparison (side-by-side)
self._render_multichart_tables(renderer, dwg, chart)
else:
# Render standard single table
self._render_single_table(renderer, dwg, chart)
def _get_house_systems_to_display(self, chart) -> list[str]:
"""Determine which house systems to display in the table.
Returns list of house system names based on config settings.
"""
if not chart.house_systems:
return []
# Check config for house_systems setting
if self.config and hasattr(self.config, "wheel"):
config_systems = self.config.wheel.house_systems
if config_systems == "all":
# Show all available house systems
return list(chart.house_systems.keys())
elif isinstance(config_systems, list):
# Show specific systems (filter to only those available)
return [s for s in config_systems if s in chart.house_systems]
# Default: just show the default house system
if chart.default_house_system:
return [chart.default_house_system]
return list(chart.house_systems.keys())[:1] # First available
def _render_single_table(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart
) -> None:
"""Render a single position table for a standard chart."""
# Standard CalculatedChart - use filter function to include angles
chart_positions = _filter_objects_for_tables(chart.positions, self.object_types)
# Get defined names from registry
name_priority = {name: i for i, name in enumerate(CELESTIAL_REGISTRY.keys())}
# Sort by object type priority, then registry order
type_priority = {
ObjectType.PLANET: 0,
ObjectType.ASTEROID: 1,
ObjectType.NODE: 2,
ObjectType.POINT: 3,
ObjectType.ANGLE: 4,
ObjectType.MIDPOINT: 5,
ObjectType.ARABIC_PART: 6,
ObjectType.FIXED_STAR: 7,
}
chart_positions.sort(
key=lambda p: (
type_priority.get(p.object_type, 99),
name_priority.get(p.name, 999),
)
)
# Build table
x_start = self.x_offset
y_start = self.y_offset
# Get column widths from config if available, otherwise use hardcoded style
if self.config and hasattr(self.config.tables, "position_col_widths"):
col_widths = self.config.tables.position_col_widths
padding = self.config.tables.padding
gap = self.config.tables.gap_between_columns
else:
# Fallback to evenly-spaced columns
col_widths = {
"planet": 100,
"sign": 50,
"degree": 60,
"house": 25,
"speed": 25,
}
padding = 10
gap = 5
# Determine which house systems to show
house_systems = (
self._get_house_systems_to_display(chart)
if self.style["show_house"]
else []
)
# Header row with column mapping
col_names = ["planet", "sign", "degree"]
headers = ["Planet", "Sign", "Degree"]
# Add house column(s) - one per system
for system_name in house_systems:
col_names.append("house")
# Use abbreviated name for header if multiple systems
if len(house_systems) > 1:
# Abbreviate system names for header
abbrev = self._abbreviate_house_system(system_name)
headers.append(abbrev)
else:
headers.append("House")
if self.style["show_speed"]:
col_names.append("speed")
headers.append("Speed")
# Calculate column x positions (cumulative widths)
col_x_positions = [x_start + padding]
for i in range(1, len(col_names)):
prev_col_name = col_names[i - 1]
prev_x = col_x_positions[i - 1]
prev_width = col_widths.get(prev_col_name, 50)
col_x_positions.append(prev_x + prev_width + gap)
# Get theme-aware colors (fallback to hardcoded if not in renderer)
text_color = renderer.style.get("text_color", self.style["text_color"])
header_color = renderer.style.get("text_color", self.style["header_color"])
# Render headers
for i, header in enumerate(headers):
x = col_x_positions[i]
dwg.add(
dwg.text(
header,
insert=(x, y_start + padding),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["header_size"],
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["header_weight"],
)
)
# Render data rows
for row_idx, pos in enumerate(chart_positions):
y = y_start + padding + ((row_idx + 1) * self.style["line_height"])
# Column 0: Planet name + glyph
glyph_info = get_glyph(pos.name)
# Get display name from registry
obj_info = CELESTIAL_REGISTRY.get(pos.name)
display_name = obj_info.display_name if obj_info else pos.name
# Render glyph and text separately to use correct fonts
x_offset = col_x_positions[0]
glyph_width = 14 # Approximate width for a glyph at 10px
glyph_y_offset = -4 # Nudge glyphs up to align with text baseline
# Skip glyph for ASC/MC where glyph equals display name
skip_glyph = pos.name in ("ASC", "MC")
if glyph_info["type"] == "unicode" and not skip_glyph:
# Render glyph with symbol font
dwg.add(
dwg.text(
glyph_info["value"],
insert=(x_offset, y + glyph_y_offset),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["font_weight"],
)
)
# Always add glyph_width to x_offset so text aligns consistently
x_offset += glyph_width
# Render display name with text font
dwg.add(
dwg.text(
display_name,
insert=(x_offset, y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Add retrograde symbol if applicable (using symbol font)
if pos.is_retrograde:
# Estimate text width to position retrograde symbol
name_width = len(display_name) * 6 # Approximate char width at 10px
dwg.add(
dwg.text(
" ℞",
insert=(x_offset + name_width, y + glyph_y_offset),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["font_weight"],
)
)
# Column 1: Sign
dwg.add(
dwg.text(
pos.sign,
insert=(col_x_positions[1], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Column 2: Degree
degrees = int(pos.sign_degree)
minutes = int((pos.sign_degree % 1) * 60)
degree_text = f"{degrees}°{minutes:02d}'"
dwg.add(
dwg.text(
degree_text,
insert=(col_x_positions[2], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# House columns (one per system)
col_idx = 3
for system_name in house_systems:
house = self._get_house_placement_for_system(chart, pos, system_name)
dwg.add(
dwg.text(
str(house) if house else "-",
insert=(col_x_positions[col_idx], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
col_idx += 1
# Speed column (if enabled)
if self.style["show_speed"]:
speed_text = f"{pos.speed_longitude:.2f}"
dwg.add(
dwg.text(
speed_text,
insert=(col_x_positions[col_idx], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
def _abbreviate_house_system(self, name: str) -> str:
"""Get abbreviated name for house system header."""
abbreviations = {
"Placidus": "Plac",
"Whole Sign": "WS",
"Koch": "Koch",
"Equal": "Equ",
"Regiomontanus": "Regio",
"Campanus": "Camp",
"Porphyry": "Porph",
"Morinus": "Mor",
"Alcabitius": "Alca",
"Topocentric": "Topo",
}
return abbreviations.get(name, name[:4])
def _get_house_placement_for_system(
self, chart, position, system_name: str
) -> int | None:
"""Get house placement for a position in a specific house system."""
if not chart.house_placements:
return None
placements = chart.house_placements.get(system_name, {})
return placements.get(position.name)
def _render_comparison_tables(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, comparison
) -> None:
"""Render two separate side-by-side tables for comparison charts."""
# Get positions from both charts
chart1_positions = _filter_objects_for_tables(
comparison.chart1.positions, self.object_types
)
chart2_positions = _filter_objects_for_tables(
comparison.chart2.positions, self.object_types
)
# Get defined names from registry
name_priority = {name: i for i, name in enumerate(CELESTIAL_REGISTRY.keys())}
# Sort both lists
type_priority = {
ObjectType.PLANET: 0,
ObjectType.ASTEROID: 1,
ObjectType.NODE: 2,
ObjectType.POINT: 3,
ObjectType.ANGLE: 4,
ObjectType.MIDPOINT: 5,
ObjectType.ARABIC_PART: 6,
ObjectType.FIXED_STAR: 7,
}
chart1_positions.sort(
key=lambda p: (
type_priority.get(p.object_type, 99),
name_priority.get(p.name, 999),
)
)
chart2_positions.sort(
key=lambda p: (
type_priority.get(p.object_type, 99),
name_priority.get(p.name, 999),
)
)
# Get column widths from config if available
if self.config and hasattr(self.config.tables, "position_col_widths"):
col_widths = self.config.tables.position_col_widths
padding = self.config.tables.padding
gap = self.config.tables.gap_between_columns
gap_between_tables = self.config.tables.gap_between_tables
else:
# Fallback
col_widths = {
"planet": 100,
"sign": 50,
"degree": 60,
"house": 25,
"speed": 25,
}
padding = 10
gap = 5
gap_between_tables = 20
# Calculate single table width from column widths
col_names = ["planet", "sign", "degree"]
if self.style["show_house"]:
col_names.append("house")
if self.style["show_speed"]:
col_names.append("speed")
single_table_width = 2 * padding # left and right padding
for i, col_name in enumerate(col_names):
single_table_width += col_widths.get(col_name, 50)
if i < len(col_names) - 1:
single_table_width += gap
# Render Chart 1 table (left)
x_chart1 = self.x_offset
y_start = self.y_offset
# Chart 1 title
title_text = f"{comparison.chart1_label or 'Chart 1'} (Inner Wheel)"
dwg.add(
dwg.text(
title_text,
insert=(x_chart1, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=self.style["header_color"],
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 1 table (offset by title height)
self._render_table_for_chart(
renderer, dwg, comparison.chart1, chart1_positions, x_chart1, y_start + 20
)
# Render Chart 2 table (right, with spacing)
x_chart2 = x_chart1 + single_table_width + gap_between_tables
# Chart 2 title
title_text = f"{comparison.chart2_label or 'Chart 2'} (Outer Wheel)"
dwg.add(
dwg.text(
title_text,
insert=(x_chart2, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=self.style["header_color"],
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 2 table (offset by title height)
self._render_table_for_chart(
renderer, dwg, comparison.chart2, chart2_positions, x_chart2, y_start + 20
)
def _render_multichart_tables(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, multichart
) -> None:
"""Render side-by-side tables for MultiChart (2+ charts)."""
# Get positions from first two charts (for biwheel display)
chart1_positions = _filter_objects_for_tables(
multichart.charts[0].positions, self.object_types
)
chart2_positions = _filter_objects_for_tables(
multichart.charts[1].positions, self.object_types
)
# Get defined names from registry
name_priority = {name: i for i, name in enumerate(CELESTIAL_REGISTRY.keys())}
# Sort both lists
type_priority = {
ObjectType.PLANET: 0,
ObjectType.ASTEROID: 1,
ObjectType.NODE: 2,
ObjectType.POINT: 3,
ObjectType.ANGLE: 4,
ObjectType.MIDPOINT: 5,
ObjectType.ARABIC_PART: 6,
ObjectType.FIXED_STAR: 7,
}
chart1_positions.sort(
key=lambda p: (
type_priority.get(p.object_type, 99),
name_priority.get(p.name, 999),
)
)
chart2_positions.sort(
key=lambda p: (
type_priority.get(p.object_type, 99),
name_priority.get(p.name, 999),
)
)
# Get column widths from config if available
if self.config and hasattr(self.config.tables, "position_col_widths"):
col_widths = self.config.tables.position_col_widths
padding = self.config.tables.padding
gap = self.config.tables.gap_between_columns
gap_between_tables = self.config.tables.gap_between_tables
else:
# Fallback
col_widths = {
"planet": 100,
"sign": 50,
"degree": 60,
"house": 25,
"speed": 25,
}
padding = 10
gap = 5
gap_between_tables = 20
# Calculate single table width from column widths
col_names = ["planet", "sign", "degree"]
if self.style["show_house"]:
col_names.append("house")
if self.style["show_speed"]:
col_names.append("speed")
single_table_width = 2 * padding # left and right padding
for i, col_name in enumerate(col_names):
single_table_width += col_widths.get(col_name, 50)
if i < len(col_names) - 1:
single_table_width += gap
# Get labels from multichart
label1 = multichart.labels[0] if multichart.labels else "Chart 1"
label2 = multichart.labels[1] if len(multichart.labels) > 1 else "Chart 2"
# Render Chart 1 table (left)
x_chart1 = self.x_offset
y_start = self.y_offset
# Chart 1 title
title_text = f"{label1} (Inner Wheel)"
dwg.add(
dwg.text(
title_text,
insert=(x_chart1, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=self.style["header_color"],
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 1 table (offset by title height)
self._render_table_for_chart(
renderer,
dwg,
multichart.charts[0],
chart1_positions,
x_chart1,
y_start + 20,
)
# Render Chart 2 table (right, with spacing)
x_chart2 = x_chart1 + single_table_width + gap_between_tables
# Chart 2 title
title_text = f"{label2} (Outer Wheel)"
dwg.add(
dwg.text(
title_text,
insert=(x_chart2, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=self.style["header_color"],
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 2 table (offset by title height)
self._render_table_for_chart(
renderer,
dwg,
multichart.charts[1],
chart2_positions,
x_chart2,
y_start + 20,
)
def _render_table_for_chart(
self,
renderer: ChartRenderer,
dwg: svgwrite.Drawing,
chart,
positions,
x_offset,
y_offset,
) -> None:
"""Render a table for a specific chart."""
x_start = x_offset
y_start = y_offset
# Get column widths from config if available, otherwise use hardcoded style
if self.config and hasattr(self.config.tables, "position_col_widths"):
col_widths = self.config.tables.position_col_widths
padding = self.config.tables.padding
gap = self.config.tables.gap_between_columns
else:
# Fallback to evenly-spaced columns
col_widths = {
"planet": 100,
"sign": 50,
"degree": 60,
"house": 25,
"speed": 25,
}
padding = 10
gap = 5
# Header row with column mapping
col_names = ["planet", "sign", "degree"]
headers = ["Planet", "Sign", "Degree"]
if self.style["show_house"]:
col_names.append("house")
headers.append("House")
if self.style["show_speed"]:
col_names.append("speed")
headers.append("Speed")
# Calculate column x positions (cumulative widths)
col_x_positions = [x_start + padding]
for i in range(1, len(col_names)):
prev_col_name = col_names[i - 1]
prev_x = col_x_positions[i - 1]
prev_width = col_widths.get(prev_col_name, 50)
col_x_positions.append(prev_x + prev_width + gap)
# Get theme-aware colors (fallback to hardcoded if not in renderer)
text_color = renderer.style.get("text_color", self.style["text_color"])
header_color = renderer.style.get("text_color", self.style["header_color"])
# Render headers
for i, header in enumerate(headers):
x = col_x_positions[i]
dwg.add(
dwg.text(
header,
insert=(x, y_start + padding),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["header_size"],
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["header_weight"],
)
)
# Render data rows
for row_idx, pos in enumerate(positions):
y = y_start + padding + ((row_idx + 1) * self.style["line_height"])
# Column 0: Planet name + glyph
glyph_info = get_glyph(pos.name)
# Get display name from registry
obj_info = CELESTIAL_REGISTRY.get(pos.name)
display_name = obj_info.display_name if obj_info else pos.name
# Render glyph and text separately to use correct fonts
x_offset = col_x_positions[0]
glyph_width = 14 # Approximate width for a glyph at 10px
glyph_y_offset = -4 # Nudge glyphs up to align with text baseline
# Skip glyph for ASC/MC where glyph equals display name
skip_glyph = pos.name in ("ASC", "MC")
if glyph_info["type"] == "unicode" and not skip_glyph:
# Render glyph with symbol font
dwg.add(
dwg.text(
glyph_info["value"],
insert=(x_offset, y + glyph_y_offset),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["font_weight"],
)
)
# Always add glyph_width to x_offset so text aligns consistently
x_offset += glyph_width
# Render display name with text font
dwg.add(
dwg.text(
display_name,
insert=(x_offset, y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Add retrograde symbol if applicable (using symbol font)
if pos.is_retrograde:
# Estimate text width to position retrograde symbol
name_width = len(display_name) * 6 # Approximate char width at 10px
dwg.add(
dwg.text(
" ℞",
insert=(x_offset + name_width, y + glyph_y_offset),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["font_weight"],
)
)
# Column 1: Sign
dwg.add(
dwg.text(
pos.sign,
insert=(col_x_positions[1], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Column 2: Degree
degrees = int(pos.sign_degree)
minutes = int((pos.sign_degree % 1) * 60)
degree_text = f"{degrees}°{minutes:02d}'"
dwg.add(
dwg.text(
degree_text,
insert=(col_x_positions[2], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Column 3: House (if enabled)
col_idx = 3
if self.style["show_house"]:
house = self._get_house_placement(chart, pos)
dwg.add(
dwg.text(
str(house) if house else "-",
insert=(col_x_positions[col_idx], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
col_idx += 1
# Column 4: Speed (if enabled)
if self.style["show_speed"]:
speed_text = f"{pos.speed_longitude:.2f}"
dwg.add(
dwg.text(
speed_text,
insert=(col_x_positions[col_idx], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
def _get_house_placement(self, chart: CalculatedChart, position) -> int | None:
"""Get house placement for a position."""
if not chart.default_house_system or not chart.house_placements:
return None
placements = chart.house_placements.get(chart.default_house_system, {})
return placements.get(position.name)
[docs]
class HouseCuspTableLayer:
"""
Renders a table of house cusps with sign placements.
Shows house number, cusp longitude, sign, and degree in sign.
Respects chart theme colors.
"""
DEFAULT_STYLE = {
"text_color": "#333333",
"header_color": "#222222",
"text_size": "10px",
"header_size": "11px",
"line_height": 16,
"col_spacing": 55, # Pixels between columns (reduced from 70 for tighter spacing)
"font_weight": "normal",
"header_weight": "bold",
}
def __init__(
self,
x_offset: float = 0,
y_offset: float = 0,
style_override: dict[str, Any] | None = None,
config: Any | None = None,
) -> None:
"""
Initialize house cusp table layer.
Args:
x_offset: X position offset from canvas origin
y_offset: Y position offset from canvas origin
style_override: Optional style overrides
config: Optional ChartVisualizationConfig for column widths, padding, etc.
"""
self.x_offset = x_offset
self.y_offset = y_offset
self.style = {**self.DEFAULT_STYLE, **(style_override or {})}
self.config = config
[docs]
def render(self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart) -> None:
"""Render house cusp table.
Handles CalculatedChart, Comparison, and MultiChart objects.
For Comparison/MultiChart, displays two separate side-by-side tables.
"""
# Check if this is a Comparison or MultiChart object
is_comparison = _is_comparison(chart)
is_multichart = _is_multichart(chart)
if is_comparison:
# Render two separate house cusp tables side by side
self._render_comparison_house_tables(renderer, dwg, chart)
elif is_multichart:
# MultiChart uses similar rendering to comparison
self._render_multichart_house_tables(renderer, dwg, chart)
else:
# Render standard single table
self._render_single_house_table(renderer, dwg, chart)
def _get_house_systems_to_display(self, chart) -> list[str]:
"""Determine which house systems to display in the table."""
if not chart.house_systems:
return []
# Check config for house_systems setting
if self.config and hasattr(self.config, "wheel"):
config_systems = self.config.wheel.house_systems
if config_systems == "all":
return list(chart.house_systems.keys())
elif isinstance(config_systems, list):
return [s for s in config_systems if s in chart.house_systems]
# Default: just show the default house system
if chart.default_house_system:
return [chart.default_house_system]
return list(chart.house_systems.keys())[:1]
def _abbreviate_house_system(self, name: str) -> str:
"""Get abbreviated name for house system header."""
abbreviations = {
"Placidus": "Plac",
"Whole Sign": "WS",
"Koch": "Koch",
"Equal": "Equ",
"Regiomontanus": "Regio",
"Campanus": "Camp",
"Porphyry": "Porph",
"Morinus": "Mor",
"Alcabitius": "Alca",
"Topocentric": "Topo",
}
return abbreviations.get(name, name[:4])
def _render_single_house_table(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart
) -> None:
"""Render a single house cusp table for a standard chart.
Supports multiple house systems as additional columns.
"""
# Get house systems to display
house_systems = self._get_house_systems_to_display(chart)
if not house_systems:
return
# Build table
x_start = self.x_offset
y_start = self.y_offset
# Get column widths from config if available
if self.config and hasattr(self.config.tables, "house_col_widths"):
col_widths = self.config.tables.house_col_widths
padding = self.config.tables.padding
gap = self.config.tables.gap_between_columns
else:
# Fallback
col_widths = {"house": 30, "sign": 50, "degree": 60}
padding = 10
gap = 5
# Build column names and headers: House + (Sign, Degree) per system
col_names = ["house"]
headers = ["House"]
for system_name in house_systems:
col_names.extend(["sign", "degree"])
if len(house_systems) > 1:
# Use abbreviated system name as header prefix
abbrev = self._abbreviate_house_system(system_name)
headers.extend([f"{abbrev}", "Deg"])
else:
headers.extend(["Sign", "Degree"])
# Calculate column x positions (cumulative widths)
col_x_positions = [x_start + padding]
for i in range(1, len(col_names)):
prev_col_name = col_names[i - 1]
prev_x = col_x_positions[i - 1]
prev_width = col_widths.get(prev_col_name, 50)
col_x_positions.append(prev_x + prev_width + gap)
# Get theme-aware colors
text_color = renderer.style.get("text_color", self.style["text_color"])
header_color = renderer.style.get("text_color", self.style["header_color"])
# Sign names for conversion
sign_names = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
]
# Render headers
for i, header in enumerate(headers):
x = col_x_positions[i]
dwg.add(
dwg.text(
header,
insert=(x, y_start + padding),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["header_size"],
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["header_weight"],
)
)
# Render data rows for all 12 houses
for house_num in range(1, 13):
y = y_start + padding + (house_num * self.style["line_height"])
# Column 0: House number
dwg.add(
dwg.text(
str(house_num),
insert=(col_x_positions[0], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Render sign and degree for each house system
col_idx = 1
for system_name in house_systems:
houses = chart.get_houses(system_name)
if not houses:
col_idx += 2
continue
cusp_longitude = houses.cusps[house_num - 1]
sign_index = int(cusp_longitude / 30)
sign_name = sign_names[sign_index % 12]
degree_in_sign = cusp_longitude % 30
# Sign column
dwg.add(
dwg.text(
sign_name,
insert=(col_x_positions[col_idx], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
col_idx += 1
# Degree column
degrees = int(degree_in_sign)
minutes = int((degree_in_sign % 1) * 60)
degree_text = f"{degrees}°{minutes:02d}'"
dwg.add(
dwg.text(
degree_text,
insert=(col_x_positions[col_idx], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
col_idx += 1
def _render_comparison_house_tables(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, comparison
) -> None:
"""Render two separate side-by-side house cusp tables for comparison charts."""
# Get house cusps from both charts
if not comparison.chart1.default_house_system:
return
if not comparison.chart2.default_house_system:
return
houses1 = comparison.chart1.get_houses(comparison.chart1.default_house_system)
houses2 = comparison.chart2.get_houses(comparison.chart2.default_house_system)
if not houses1 or not houses2:
return
# Get config values
if self.config and hasattr(self.config.tables, "house_col_widths"):
col_widths = self.config.tables.house_col_widths
padding = self.config.tables.padding
gap_between_cols = self.config.tables.gap_between_columns
gap_between_tables = self.config.tables.gap_between_tables
else:
# Fallback
col_widths = {"house": 30, "sign": 50, "degree": 60}
padding = 10
gap_between_cols = 5
gap_between_tables = 20
# Calculate single table width: padding + columns + gaps + padding
col_names = ["house", "sign", "degree"]
single_table_width = 2 * padding
for i, col_name in enumerate(col_names):
single_table_width += col_widths.get(col_name, 50)
if i < len(col_names) - 1:
single_table_width += gap_between_cols
# Render Chart 1 house table (left)
x_chart1 = self.x_offset
y_start = self.y_offset
# Get theme-aware colors
text_color = renderer.style.get("text_color", self.style["text_color"])
header_color = renderer.style.get("text_color", self.style["header_color"])
# Chart 1 title
title_text = f"{comparison.chart1_label or 'Chart 1'} Houses"
dwg.add(
dwg.text(
title_text,
insert=(x_chart1, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 1 house table (offset by title height)
self._render_house_table_for_chart(
renderer,
dwg,
houses1,
x_chart1,
y_start + 20,
col_widths,
padding,
gap_between_cols,
text_color,
header_color,
)
# Render Chart 2 house table (right, with spacing)
x_chart2 = x_chart1 + single_table_width + gap_between_tables
# Chart 2 title
title_text = f"{comparison.chart2_label or 'Chart 2'} Houses"
dwg.add(
dwg.text(
title_text,
insert=(x_chart2, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 2 house table (offset by title height)
self._render_house_table_for_chart(
renderer,
dwg,
houses2,
x_chart2,
y_start + 20,
col_widths,
padding,
gap_between_cols,
text_color,
header_color,
)
def _render_multichart_house_tables(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, multichart
) -> None:
"""Render two separate side-by-side house cusp tables for MultiChart."""
chart1 = multichart.charts[0]
chart2 = multichart.charts[1]
# Get house cusps from both charts
if not chart1.default_house_system:
return
if not chart2.default_house_system:
return
houses1 = chart1.get_houses(chart1.default_house_system)
houses2 = chart2.get_houses(chart2.default_house_system)
if not houses1 or not houses2:
return
# Get config values
if self.config and hasattr(self.config.tables, "house_col_widths"):
col_widths = self.config.tables.house_col_widths
padding = self.config.tables.padding
gap_between_cols = self.config.tables.gap_between_columns
gap_between_tables = self.config.tables.gap_between_tables
else:
# Fallback
col_widths = {"house": 30, "sign": 50, "degree": 60}
padding = 10
gap_between_cols = 5
gap_between_tables = 20
# Calculate single table width: padding + columns + gaps + padding
col_names = ["house", "sign", "degree"]
single_table_width = 2 * padding
for i, col_name in enumerate(col_names):
single_table_width += col_widths.get(col_name, 50)
if i < len(col_names) - 1:
single_table_width += gap_between_cols
# Get labels from multichart
label1 = multichart.labels[0] if multichart.labels else "Chart 1"
label2 = multichart.labels[1] if len(multichart.labels) > 1 else "Chart 2"
# Render Chart 1 house table (left)
x_chart1 = self.x_offset
y_start = self.y_offset
# Get theme-aware colors
text_color = renderer.style.get("text_color", self.style["text_color"])
header_color = renderer.style.get("text_color", self.style["header_color"])
# Chart 1 title
title_text = f"{label1} Houses"
dwg.add(
dwg.text(
title_text,
insert=(x_chart1, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 1 house table (offset by title height)
self._render_house_table_for_chart(
renderer,
dwg,
houses1,
x_chart1,
y_start + 20,
col_widths,
padding,
gap_between_cols,
text_color,
header_color,
)
# Render Chart 2 house table (right, with spacing)
x_chart2 = x_chart1 + single_table_width + gap_between_tables
# Chart 2 title
title_text = f"{label2} Houses"
dwg.add(
dwg.text(
title_text,
insert=(x_chart2, y_start),
text_anchor="start",
dominant_baseline="hanging",
font_size="12px",
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight="bold",
)
)
# Render chart 2 house table (offset by title height)
self._render_house_table_for_chart(
renderer,
dwg,
houses2,
x_chart2,
y_start + 20,
col_widths,
padding,
gap_between_cols,
text_color,
header_color,
)
def _render_house_table_for_chart(
self,
renderer: ChartRenderer,
dwg: svgwrite.Drawing,
houses,
x_offset,
y_offset,
col_widths,
padding,
gap,
text_color,
header_color,
) -> None:
"""Render a house cusp table for a specific chart."""
x_start = x_offset
y_start = y_offset
# Header row with column mapping
col_names = ["house", "sign", "degree"]
headers = ["House", "Sign", "Degree"]
# Calculate column x positions (cumulative widths)
col_x_positions = [x_start + padding]
for i in range(1, len(col_names)):
prev_col_name = col_names[i - 1]
prev_x = col_x_positions[i - 1]
prev_width = col_widths.get(prev_col_name, 50)
col_x_positions.append(prev_x + prev_width + gap)
# Render headers
for i, header in enumerate(headers):
x = col_x_positions[i]
dwg.add(
dwg.text(
header,
insert=(x, y_start + padding),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["header_size"],
fill=header_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["header_weight"],
)
)
# Render data rows for all 12 houses
for house_num in range(1, 13):
y = y_start + padding + (house_num * self.style["line_height"])
# Get cusp longitude
cusp_longitude = houses.cusps[house_num - 1]
# Calculate sign and degree
sign_index = int(cusp_longitude / 30)
sign_names = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
]
sign_name = sign_names[sign_index % 12]
degree_in_sign = cusp_longitude % 30
# Column 0: House number
house_text = f"{house_num}"
dwg.add(
dwg.text(
house_text,
insert=(col_x_positions[0], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Column 1: Sign
dwg.add(
dwg.text(
sign_name,
insert=(col_x_positions[1], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
# Column 2: Degree
degrees = int(degree_in_sign)
minutes = int((degree_in_sign % 1) * 60)
degree_text = f"{degrees}°{minutes:02d}'"
dwg.add(
dwg.text(
degree_text,
insert=(col_x_positions[2], y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_text"],
font_weight=self.style["font_weight"],
)
)
[docs]
class AspectarianLayer:
"""
Renders an aspectarian grid (triangle aspect table).
Shows aspects between all planets in a classic triangle grid format.
Respects chart theme colors.
Supports two modes:
- Simple (default): Large aspect glyphs only
- Detailed: Smaller glyphs with orb value and A/S (applying/separating) indicator
"""
DEFAULT_STYLE = {
"text_color": "#333333",
"header_color": "#222222",
"grid_color": "#CCCCCC",
"text_size": "14px", # Larger glyph for simple mode
"text_size_detailed": "11px", # Smaller glyph for detailed mode
"orb_size": "6px", # Small font for orb text
"header_size": "10px",
"cell_size": 24, # Size of each grid cell
"font_weight": "normal",
"header_weight": "bold",
"show_grid": True,
}
def __init__(
self,
x_offset: float = 0,
y_offset: float = 0,
style_override: dict[str, Any] | None = None,
object_types: list[str | ObjectType] | None = None,
config: Any | None = None,
detailed: bool = False,
) -> None:
"""
Initialize aspectarian layer.
Args:
x_offset: X position offset from canvas origin
y_offset: Y position offset from canvas origin
style_override: Optional style overrides
object_types: Optional list of object types to include.
If None, uses default (planet, asteroid, point, node, angle).
Examples: ["planet", "asteroid", "midpoint"]
config: Optional ChartVisualizationConfig for cell sizing, padding, etc.
detailed: If True, show orb and applying/separating indicator in cells.
If False (default), show larger glyphs only.
"""
self.x_offset = x_offset
self.y_offset = y_offset
self.style = {**self.DEFAULT_STYLE, **(style_override or {})}
self.object_types = object_types
self.config = config
self.detailed = detailed
[docs]
def render(self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart) -> None:
"""Render aspectarian grid.
Handles CalculatedChart, Comparison, and MultiChart objects.
For Comparison/MultiChart, displays cross-chart aspects including Asc and MC from both charts.
"""
# Check if this is a Comparison or MultiChart object
is_comparison = _is_comparison(chart)
is_multichart = _is_multichart(chart)
cell_size = self.style["cell_size"]
padding = self.style.get("label_padding", 4)
if is_comparison or is_multichart:
# Get chart1 and chart2 positions (different access for Comparison vs MultiChart)
if is_multichart:
chart1_positions = chart.charts[0].positions
chart2_positions = chart.charts[1].positions
cross_aspects = chart.get_all_cross_aspects()
else:
chart1_positions = chart.chart1.positions
chart2_positions = chart.chart2.positions
cross_aspects = chart.cross_aspects
# For comparisons: get all celestial objects using filter function
# Chart1 objects (rows - inner wheel)
chart1_objects = _filter_objects_for_tables(
chart1_positions, self.object_types
)
# Chart2 objects (columns - outer wheel)
chart2_objects = _filter_objects_for_tables(
chart2_positions, self.object_types
)
# Sort by traditional order (planets first, nodes, points, then angles)
object_order = [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
"North Node",
"True Node",
"Mean Node",
"ASC",
"AC",
"Ascendant",
"MC",
"Midheaven",
]
chart1_objects.sort(
key=lambda p: (
object_order.index(p.name) if p.name in object_order else 99
)
)
chart2_objects.sort(
key=lambda p: (
object_order.index(p.name) if p.name in object_order else 99
)
)
# Build aspect lookup from cross_aspects
aspect_lookup = {}
for aspect in cross_aspects:
# Key format: (chart1_obj_name, chart2_obj_name)
key = (aspect.object1.name, aspect.object2.name)
aspect_lookup[key] = aspect
# Use chart1_objects for rows, chart2_objects for columns
row_objects = chart1_objects
col_objects = chart2_objects
else:
# Standard CalculatedChart - use filter function to include angles and nodes
planets = _filter_objects_for_tables(chart.positions, self.object_types)
# Sort by traditional order (planets, nodes, points, angles)
planet_order = [
"Sun",
"Moon",
"Mercury",
"Venus",
"Mars",
"Jupiter",
"Saturn",
"Uranus",
"Neptune",
"Pluto",
"North Node",
"True Node",
"Mean Node",
"ASC",
"AC",
"Ascendant",
"MC",
"Midheaven",
]
planets.sort(
key=lambda p: (
planet_order.index(p.name) if p.name in planet_order else 99
)
)
# Build aspect lookup
aspect_lookup = {}
for aspect in chart.aspects:
key1 = (aspect.object1.name, aspect.object2.name)
key2 = (aspect.object2.name, aspect.object1.name)
aspect_lookup[key1] = aspect
aspect_lookup[key2] = aspect
# Use planets for both rows and columns (traditional triangle grid)
row_objects = planets
col_objects = planets
# Render grid
cell_size = self.style["cell_size"]
x_start = self.x_offset
y_start = self.y_offset
if is_comparison:
# For comparisons: full rectangular grid (chart1 rows × chart2 columns)
# Column headers (chart2 objects - outer wheel) - aligned at left edge of column
for col_idx, obj in enumerate(col_objects):
glyph_info = get_glyph(obj.name)
glyph = (
glyph_info["value"]
if glyph_info["type"] == "unicode"
else obj.name[:2]
)
# Add 2 indicator for chart2
glyph = f"{glyph}₂"
# Center of the column (offset by cell_size for row header column)
x = x_start + cell_size + (col_idx * cell_size) + (cell_size / 2)
# Bottom of the text sits just above the first row (y_start + cell_size)
# We subtract the padding from the top of the grid
y = y_start + cell_size - padding
dwg.add(
dwg.text(
glyph,
insert=(x, y),
text_anchor="middle", # Center aligned
# dominant_baseline="hanging",
font_size=self.style["header_size"],
fill=self.style["header_color"],
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["header_weight"],
)
)
# Row headers (chart1 objects - inner wheel) and grid cells
for row_idx, obj_row in enumerate(row_objects):
glyph_info = get_glyph(obj_row.name)
glyph = (
glyph_info["value"]
if glyph_info["type"] == "unicode"
else obj_row.name[:2]
)
# Add 1 indicator for chart1
glyph = f"{glyph}₁"
# Center of the row vertically
y_row_center = y_start + ((row_idx + 1) * cell_size) + (cell_size / 2)
# Right-align text against the grid edge (x_start + cell_size)
x_text = x_start + cell_size - padding
# Row header
dwg.add(
dwg.text(
glyph,
insert=(x_text, y_row_center),
text_anchor="end", # Right aligned (tight to grid)
dominant_baseline="middle",
font_size=self.style["header_size"],
fill=self.style["header_color"],
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["header_weight"],
)
)
# Grid cells (all columns for rectangular grid)
for col_idx, obj_col in enumerate(col_objects):
cell_x_left = x_start + cell_size + (col_idx * cell_size)
cell_x_center = cell_x_left + (cell_size / 2)
# Draw grid lines if enabled
if self.style["show_grid"]:
cell_y = y_start + ((row_idx + 1) * cell_size)
dwg.add(
dwg.rect(
insert=(cell_x_left, cell_y),
size=(cell_size, cell_size),
fill="none",
stroke=self.style["grid_color"],
stroke_width=0.5,
)
)
# Aspects
aspect_key = (obj_row.name, obj_col.name)
if aspect_key in aspect_lookup:
self._render_aspect_glyph(
dwg,
renderer,
aspect_lookup[aspect_key],
cell_x_center,
y_row_center,
)
else:
# === SINGLE CHART: TRIANGLE GRID ===
# Column headers (Top - Stair Step)
# Only go up to len - 1 because the last planet never heads a column in a triangle
for col_idx in range(len(row_objects) - 1):
obj = row_objects[col_idx]
glyph_info = get_glyph(obj.name)
glyph = (
glyph_info["value"]
if glyph_info["type"] == "unicode"
else obj.name[:2]
)
# Center of the column
x = x_start + ((col_idx + 1) * cell_size) + (cell_size / 2)
# STAIR STEP CALCULATION:
# The column for planet index `i` starts at row index `i + 1`.
# We want the label to sit on top of that first box.
# Top of first box = y_start + ((col_idx + 1) * cell_size)
y = y_start + ((col_idx + 1) * cell_size) - padding
dwg.add(
dwg.text(
glyph,
insert=(x, y),
text_anchor="middle", # Center aligned
# dominant_baseline="hanging",
font_size=self.style["header_size"],
fill=self.style["header_color"],
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["header_weight"],
)
)
# Row headers (left) and grid cells (lower triangle only)
for row_idx in range(1, len(row_objects)):
obj_row = row_objects[row_idx]
glyph_info = get_glyph(obj_row.name)
glyph = (
glyph_info["value"]
if glyph_info["type"] == "unicode"
else obj_row.name[:2]
)
y_row_center = y_start + (row_idx * cell_size) + (cell_size / 2)
# Right-align text against the grid edge
x_text = x_start + cell_size - padding
# Row header
dwg.add(
dwg.text(
glyph,
insert=(x_text, y_row_center),
text_anchor="end", # Right aligned
dominant_baseline="middle",
font_size=self.style["header_size"],
fill=self.style["header_color"],
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["header_weight"],
)
)
# Grid cells (only lower triangle)
for col_idx in range(row_idx):
obj_col = row_objects[col_idx]
cell_x_left = x_start + cell_size + (col_idx * cell_size)
cell_x_center = cell_x_left + (cell_size / 2)
# Draw grid lines if enabled
if self.style["show_grid"]:
# Cell border
cell_y = y_start + (row_idx * cell_size)
dwg.add(
dwg.rect(
insert=(cell_x_left, cell_y),
size=(cell_size, cell_size),
fill="none",
stroke=self.style["grid_color"],
stroke_width=0.5,
)
)
# Check for aspect
aspect_key = (obj_row.name, obj_col.name)
if aspect_key in aspect_lookup:
self._render_aspect_glyph(
dwg,
renderer,
aspect_lookup[aspect_key],
cell_x_center,
y_row_center,
)
def _render_aspect_glyph(
self,
dwg: svgwrite.Drawing,
renderer: ChartRenderer,
aspect: Aspect,
x: float,
y: float,
):
"""Helper to render the aspect glyph in a cell.
In simple mode (default): renders a larger glyph centered in the cell.
In detailed mode: renders a smaller glyph with orb and A/S indicator below.
"""
aspect_info = get_aspect_info(aspect.aspect_name)
if aspect_info and aspect_info.glyph:
aspect_glyph = aspect_info.glyph
else:
aspect_glyph = aspect.aspect_name[:1]
# Get color from aspect palette (renderer.style["aspects"])
aspect_style_dict = renderer.style.get("aspects", {})
aspect_style = aspect_style_dict.get(
aspect.aspect_name, aspect_style_dict.get("default", {})
)
if isinstance(aspect_style, dict):
text_color = aspect_style.get("color", self.style["text_color"])
else:
text_color = self.style["text_color"]
if self.detailed:
# Detailed mode: smaller glyph at top, orb + A/S at bottom
glyph_y = y - 4 # Shift glyph up slightly
# Render smaller aspect glyph
dwg.add(
dwg.text(
aspect_glyph,
insert=(x, glyph_y),
text_anchor="middle",
dominant_baseline="middle",
font_size=self.style["text_size_detailed"],
fill=text_color,
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["font_weight"],
)
)
# Build orb + A/S text
orb_text = f"{aspect.orb:.0f}°"
if aspect.is_applying is not None:
orb_text += "A" if aspect.is_applying else "S"
# Render orb text below glyph
orb_y = y + 5 # Below the glyph
# Get theme-aware color for orb text (use info_color like other info text)
orb_color = renderer.style.get("planets", {}).get(
"info_color", self.style["text_color"]
)
dwg.add(
dwg.text(
orb_text,
insert=(x, orb_y),
text_anchor="middle",
dominant_baseline="middle",
font_size=self.style["orb_size"],
fill=orb_color,
font_family=renderer.style["font_family_text"],
font_weight="normal",
)
)
else:
# Simple mode: larger glyph centered in cell
dwg.add(
dwg.text(
aspect_glyph,
insert=(x, y),
text_anchor="middle",
dominant_baseline="middle",
font_size=self.style["text_size"],
fill=text_color,
font_family=renderer.style["font_family_glyphs"],
font_weight=self.style["font_weight"],
)
)
# =============================================================================
# Standalone Aspectarian Generator
# =============================================================================
[docs]
def generate_aspectarian_svg(
chart,
output_path: str | None = None,
cell_size: int | None = None,
detailed: bool = False,
theme: str | None = None,
aspect_palette: str | None = None,
padding: int | None = None,
) -> str:
"""
Generate a standalone aspectarian SVG (triangle for single charts, square for comparisons).
Args:
chart: CalculatedChart or Comparison object
output_path: Optional path to save SVG file. If None, returns SVG string.
cell_size: Size of each grid cell in pixels (default: from config, typically 24)
detailed: If True, show orb and applying/separating indicator (default: False)
theme: Optional theme name (e.g., "dark", "midnight")
aspect_palette: Optional aspect color palette
padding: Padding around the grid in pixels (default: from config, typically 10)
Returns:
SVG string if output_path is None, otherwise the output_path
Example:
# Generate and save triangle aspectarian
chart = ChartBuilder.from_notable("Albert Einstein").with_aspects().calculate()
generate_aspectarian_svg(chart, "einstein_aspects.svg")
# Generate square aspectarian for synastry
comparison = ComparisonBuilder.synastry(chart1, chart2).calculate()
svg_string = generate_aspectarian_svg(comparison)
# With styling
generate_aspectarian_svg(
chart,
"aspects.svg",
cell_size=28,
detailed=True,
theme="midnight",
)
"""
from stellium.visualization.config import (
ChartVisualizationConfig,
ChartWheelConfig,
InfoCornerConfig,
TableConfig,
)
from stellium.visualization.layout.measurer import ContentMeasurer
# Create config with overrides
table_config_kwargs = {"aspectarian_detailed": detailed}
if cell_size is not None:
table_config_kwargs["aspectarian_cell_size"] = cell_size
if padding is not None:
table_config_kwargs["padding"] = padding
table_config = TableConfig(**table_config_kwargs)
is_comparison = _is_comparison(chart)
config = ChartVisualizationConfig(
wheel=ChartWheelConfig(chart_type="biwheel" if is_comparison else "single"),
corners=InfoCornerConfig(),
tables=table_config,
)
# Use measurer for dimensions
measurer = ContentMeasurer()
dims = measurer.measure_aspectarian(chart, config)
# Add padding
actual_padding = padding if padding is not None else config.tables.padding
width = dims.width + (2 * actual_padding)
height = dims.height + (2 * actual_padding)
# Create renderer (for style/font info)
renderer = ChartRenderer(
size=max(width, height),
theme=theme,
aspect_palette=aspect_palette,
)
# Create SVG drawing
dwg = svgwrite.Drawing(
filename=output_path or "aspectarian.svg",
size=(f"{width}px", f"{height}px"),
profile="full",
)
# Add background
bg_color = renderer.style.get("background_color", "#FFFFFF")
dwg.add(dwg.rect(insert=(0, 0), size=(width, height), fill=bg_color))
# Create and render aspectarian layer
actual_cell_size = (
cell_size if cell_size is not None else config.tables.aspectarian_cell_size
)
layer = AspectarianLayer(
x_offset=actual_padding,
y_offset=actual_padding,
style_override={"cell_size": actual_cell_size},
detailed=detailed,
)
layer.render(renderer, dwg, chart)
# Save or return
if output_path:
dwg.saveas(output_path)
return output_path
else:
return dwg.tostring()
[docs]
def get_aspectarian_dimensions(
chart,
cell_size: int | None = None,
padding: int | None = None,
) -> tuple[int, int]:
"""
Calculate the dimensions of an aspectarian SVG without rendering.
Uses the ContentMeasurer for consistency with the rest of the visualization system.
Args:
chart: CalculatedChart or Comparison object
cell_size: Size of each grid cell in pixels (default: from config, typically 24)
padding: Padding around the grid in pixels (default: from config, typically 10)
Returns:
Tuple of (width, height) in pixels
"""
from stellium.visualization.config import (
ChartVisualizationConfig,
ChartWheelConfig,
InfoCornerConfig,
TableConfig,
)
from stellium.visualization.layout.measurer import ContentMeasurer
# Create config with overrides
table_config_kwargs = {}
if cell_size is not None:
table_config_kwargs["aspectarian_cell_size"] = cell_size
if padding is not None:
table_config_kwargs["padding"] = padding
table_config = TableConfig(**table_config_kwargs)
is_comparison = _is_comparison(chart)
config = ChartVisualizationConfig(
wheel=ChartWheelConfig(chart_type="biwheel" if is_comparison else "single"),
corners=InfoCornerConfig(),
tables=table_config,
)
# Use measurer
measurer = ContentMeasurer()
dims = measurer.measure_aspectarian(chart, config)
# Add padding
actual_padding = padding if padding is not None else config.tables.padding
width = dims.width + (2 * actual_padding)
height = dims.height + (2 * actual_padding)
return (width, height)