"""South Indian style Vedic chart renderer.
The South Indian chart is a 4×4 grid where the 4 center cells are
merged, creating 12 outer cells. Each cell represents a fixed zodiac
sign (Aries is always in the same position). Planets are placed in
the cell corresponding to their sign.
Grid layout (sign indices 0-11, where 0=Aries):
┌──────┬──────┬──────┬──────┐
│ 11 │ 0 │ 1 │ 2 │
│ Pisc │ Arie │ Taur │ Gemi │
├──────┼──────┴──────┼──────┤
│ 10 │ │ 3 │
│ Aqua │ (center) │ Canc │
├──────┤ ├──────┤
│ 9 │ │ 4 │
│ Capr │ │ Leo │
├──────┼──────┬──────┼──────┤
│ 8 │ 7 │ 6 │ 5 │
│ Sagi │ Scor │ Libr │ Virg │
└──────┴──────┴──────┴──────┘
"""
from __future__ import annotations
from dataclasses import dataclass
import svgwrite
from stellium.core.models import CalculatedChart
from stellium.visualization.core import ZODIAC_GLYPHS, get_glyph
# ── Sign names and glyphs ──────────────────────────────────────────
_SIGN_NAMES = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
]
_SIGN_ABBREV = [
"Ari",
"Tau",
"Gem",
"Can",
"Leo",
"Vir",
"Lib",
"Sco",
"Sag",
"Cap",
"Aqu",
"Pis",
]
_PLANET_ABBREV: dict[str, str] = {
"Sun": "Su",
"Moon": "Mo",
"Mercury": "Me",
"Venus": "Ve",
"Mars": "Ma",
"Jupiter": "Ju",
"Saturn": "Sa",
"Uranus": "Ur",
"Neptune": "Ne",
"Pluto": "Pl",
"True Node": "Ra",
"South Node": "Ke",
"Chiron": "Ch",
"Black Moon Lilith": "Li",
"Part of Fortune": "PoF",
"Ceres": "Ce",
"Pallas": "Pa",
"Juno": "Jn",
"Vesta": "Ve",
}
# Font stack for unicode zodiac/planet glyphs (same as Western chart system)
_GLYPH_FONT = (
'"Symbola", "Noto Sans Symbols", "Apple Symbols", "Segoe UI Symbol", serif'
)
# Cell positions: sign_index → (row, col) in the 4×4 grid
# Arranged counterclockwise from Pisces (top-left)
_SIGN_CELLS: dict[int, tuple[int, int]] = {
0: (0, 1), # Aries — top row, 2nd col
1: (0, 2), # Taurus
2: (0, 3), # Gemini
3: (1, 3), # Cancer
4: (2, 3), # Leo
5: (3, 3), # Virgo
6: (3, 2), # Libra
7: (3, 1), # Scorpio
8: (3, 0), # Sagittarius
9: (2, 0), # Capricorn
10: (1, 0), # Aquarius
11: (0, 0), # Pisces
}
# ── Themes ─────────────────────────────────────────────────────────
_THEMES: dict[str, dict[str, str]] = {
"classic": {
"bg": "#ffffff",
"line": "#333333",
"sign_text": "#666666",
"planet_text": "#222222",
"house_marker": "#cc4444",
"center_bg": "#fafaf5",
"asc_bg": "#fff3e0",
},
"dark": {
"bg": "#1a1a2e",
"line": "#555577",
"sign_text": "#8888aa",
"planet_text": "#cccccc",
"house_marker": "#ff8866",
"center_bg": "#22223a",
"asc_bg": "#2a2a40",
},
"traditional": {
"bg": "#fdf6e3",
"line": "#8b4513",
"sign_text": "#8b6914",
"planet_text": "#333333",
"house_marker": "#cc3300",
"center_bg": "#faf0d7",
"asc_bg": "#fff0cc",
},
}
[docs]
@dataclass(frozen=True)
class VedicPlanetInfo:
"""Planet data for placement in a Vedic chart cell."""
name: str
glyph: str
sign_index: int
degree: float
is_retrograde: bool
[docs]
class SouthIndianRenderer:
"""Render a South Indian style Vedic chart as SVG.
Usage::
renderer = SouthIndianRenderer(size=500, theme="classic")
svg_string = renderer.render(chart)
# Or save directly:
renderer.render_to_file(chart, "vedic_south.svg")
"""
def __init__(
self,
size: int = 500,
theme: str = "classic",
show_degrees: bool = False,
show_house_numbers: bool = True,
label_style: str = "abbreviation",
) -> None:
"""
Args:
size: SVG width/height in pixels.
theme: Color theme — "classic", "dark", or "traditional".
show_degrees: Show degree + minutes for each planet.
show_house_numbers: Show house numbers in each cell.
label_style: How to render signs/planets —
"abbreviation" (Su, Mo, Ari, Tau — traditional Vedic style),
"glyph" (☉, ☽, ♈, ♉ — Unicode symbols),
or "full" (Sun, Moon, Aries, Taurus — full names).
"""
self.size = size
self.cell_size = size / 4
self.theme = _THEMES.get(theme, _THEMES["classic"])
self.show_degrees = show_degrees
self.show_house_numbers = show_house_numbers
self.label_style = label_style
def _sign_label(self, sign_idx: int) -> str:
"""Get the sign label in the current label style."""
if self.label_style == "number":
return str(sign_idx + 1)
elif self.label_style == "glyph":
return ZODIAC_GLYPHS[sign_idx]
elif self.label_style == "full":
return _SIGN_NAMES[sign_idx]
return _SIGN_ABBREV[sign_idx]
def _planet_label(self, planet: VedicPlanetInfo) -> str:
"""Get the planet label in the current label style with optional degree."""
if self.label_style == "glyph":
name_part = planet.glyph
elif self.label_style == "full":
name_part = planet.name
else:
name_part = _PLANET_ABBREV.get(planet.name, planet.name[:2])
if planet.is_retrograde:
name_part += " R"
if self.show_degrees:
deg = int(planet.degree)
mins = int((planet.degree - deg) * 60)
name_part += f" {deg}°{mins:02d}'"
return name_part
def _sign_font_family(self) -> str:
"""Font family depending on label style."""
if self.label_style == "glyph":
return _GLYPH_FONT
return "sans-serif"
def _get_chart_info(
self, chart: CalculatedChart
) -> list[tuple[str, int, str, str]]:
"""Extract chart info lines: (text, font_size, font_weight, color)."""
th = self.theme
lines = []
# Name
name = chart.metadata.get("name") if hasattr(chart, "metadata") else None
if name:
lines.append((name, 13, "bold", th["planet_text"]))
# Datetime
if hasattr(chart, "datetime") and chart.datetime:
if chart.datetime.local_datetime:
dt_str = chart.datetime.local_datetime.strftime("%b %d, %Y %I:%M %p")
else:
dt_str = chart.datetime.utc_datetime.strftime("%b %d, %Y %H:%M UTC")
lines.append((dt_str, 10, "normal", th["sign_text"]))
# Location
if hasattr(chart, "location") and chart.location:
loc_name = getattr(chart.location, "name", None)
if loc_name:
lines.append((loc_name, 10, "normal", th["sign_text"]))
return lines
def _get_planets_by_sign(
self, chart: CalculatedChart
) -> dict[int, list[VedicPlanetInfo]]:
"""Group planets by their zodiac sign index (0=Aries)."""
by_sign: dict[int, list[VedicPlanetInfo]] = {i: [] for i in range(12)}
for pos in chart.get_planets():
sign_idx = int(pos.longitude // 30)
degree = pos.longitude % 30
glyph_info = get_glyph(pos.name)
is_retro = pos.speed_longitude < 0 if pos.speed_longitude else False
by_sign[sign_idx].append(
VedicPlanetInfo(
name=pos.name,
glyph=glyph_info["value"],
sign_index=sign_idx,
degree=degree,
is_retrograde=is_retro,
)
)
return by_sign
def _get_asc_sign_index(self, chart: CalculatedChart) -> int:
"""Get the sign index where the ASC falls."""
houses = chart.get_houses()
if houses and houses.cusps:
asc_long = houses.cusps[0] # first cusp = ASC
return int(asc_long // 30)
return 0
def _cell_rect(self, row: int, col: int) -> tuple[float, float, float, float]:
"""Get (x, y, width, height) for a grid cell."""
cs = self.cell_size
return (col * cs, row * cs, cs, cs)
[docs]
def render(self, chart: CalculatedChart) -> str:
"""Render the chart as an SVG string."""
th = self.theme
cs = self.cell_size
size = self.size
dwg = svgwrite.Drawing(size=(size, size))
# Background
dwg.add(dwg.rect(insert=(0, 0), size=(size, size), fill=th["bg"]))
# Center area background
dwg.add(
dwg.rect(
insert=(cs, cs),
size=(cs * 2, cs * 2),
fill=th["center_bg"],
)
)
planets_by_sign = self._get_planets_by_sign(chart)
asc_sign = self._get_asc_sign_index(chart)
# Compute house numbers: house 1 = ASC sign, house 2 = next sign, etc.
house_for_sign: dict[int, int] = {}
for h in range(12):
sign_idx = (asc_sign + h) % 12
house_for_sign[sign_idx] = h + 1
# Draw cells
for sign_idx, (row, col) in _SIGN_CELLS.items():
x, y, w, h = self._cell_rect(row, col)
is_asc = sign_idx == asc_sign
# ASC cell highlight
if is_asc:
dwg.add(dwg.rect(insert=(x, y), size=(w, h), fill=th["asc_bg"]))
# Cell border
dwg.add(
dwg.rect(
insert=(x, y),
size=(w, h),
fill="none",
stroke=th["line"],
stroke_width=1.5,
)
)
# Sign label (top-left corner)
dwg.add(
dwg.text(
self._sign_label(sign_idx),
insert=(x + 4, y + 14),
font_family=self._sign_font_family(),
font_size=11,
fill=th["sign_text"],
)
)
# House number (top-right corner)
if self.show_house_numbers:
house_num = house_for_sign.get(sign_idx, 0)
dwg.add(
dwg.text(
str(house_num),
insert=(x + w - 6, y + 14),
font_family="sans-serif",
font_size=9,
fill=th["house_marker"],
text_anchor="end",
)
)
# ASC marker
if is_asc:
dwg.add(
dwg.text(
"ASC",
insert=(x + w - 6, y + h - 4),
font_family="sans-serif",
font_size=8,
fill=th["house_marker"],
text_anchor="end",
font_weight="bold",
)
)
# Planets in this sign
planets = planets_by_sign.get(sign_idx, [])
planet_y = y + 30 # start below the sign label
for _pi, planet in enumerate(planets):
label = self._planet_label(planet)
dwg.add(
dwg.text(
label,
insert=(x + w / 2, planet_y),
font_family=self._sign_font_family(),
font_size=12,
fill=th["planet_text"],
text_anchor="middle",
)
)
planet_y += 16
# Center text: chart info (name, datetime, location)
cx = size / 2
cy = size / 2
info_lines = self._get_chart_info(chart)
# Stack lines centered in the middle area
total_lines = len(info_lines) + 1 # +1 for "South Indian" label
line_h = 16
start_y = cy - (total_lines * line_h) / 2 + line_h / 2
for i, (text, font_size, weight, color) in enumerate(info_lines):
dwg.add(
dwg.text(
text,
insert=(cx, start_y + i * line_h),
font_family="sans-serif",
font_size=font_size,
fill=color,
text_anchor="middle",
font_weight=weight,
)
)
# "South Indian" label at bottom of center block
dwg.add(
dwg.text(
"South Indian",
insert=(cx, start_y + len(info_lines) * line_h),
font_family="sans-serif",
font_size=9,
fill=th["sign_text"],
text_anchor="middle",
font_style="italic",
)
)
return dwg.tostring()
[docs]
def render_to_file(self, chart: CalculatedChart, path: str) -> None:
"""Render and save to an SVG file."""
from pathlib import Path as _Path
_Path(path).write_text(self.render(chart))