Source code for stellium.visualization.layers.chart_frame
"""
Chart frame layers - header, borders, and ring boundaries.
"""
from typing import Any
import svgwrite
from stellium.core.models import (
CalculatedChart,
UnknownTimeChart,
)
from stellium.visualization.core import (
ChartRenderer,
)
__all__ = ["HeaderLayer", "RingBoundaryLayer", "OuterBorderLayer"]
[docs]
class HeaderLayer:
"""
Renders the chart header band at the top of the canvas.
Displays native information prominently:
- Single chart: Name, location, datetime, timezone, coordinates
- Biwheel: Two-column layout with chart1 info left-aligned, chart2 right-aligned
- Synthesis: "Composite: Name1 & Name2" or "Davison: Name1 & Name2" with midpoint info
The header uses Baskerville italic-semibold for names (elegant, classical feel)
and the normal text font for details.
"""
def __init__(
self,
height: int = 70,
name_font_size: str = "18px",
name_font_family: str = "Baskerville, 'Libre Baskerville', Georgia, serif",
name_font_weight: str = "600", # Semibold (falls back to bold if unavailable)
name_font_style: str = "italic",
details_font_size: str = "12px",
line_height: int = 16,
coord_precision: int = 4,
) -> None:
"""
Initialize header layer.
Args:
height: Header height in pixels
name_font_size: Font size for name(s)
name_font_family: Font family for name(s)
name_font_weight: Font weight for name(s) - "600" for semibold, "bold" for bold
name_font_style: Font style for name(s) - "italic" or "normal"
details_font_size: Font size for details
line_height: Line height for detail rows
coord_precision: Decimal places for coordinates
"""
self.height = height
self.name_font_size = name_font_size
self.name_font_family = name_font_family
self.name_font_weight = name_font_weight
self.name_font_style = name_font_style
self.details_font_size = details_font_size
self.line_height = line_height
self.coord_precision = coord_precision
[docs]
def render(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart: CalculatedChart
) -> None:
"""Render the header band."""
from stellium.core.comparison import Comparison
from stellium.core.multichart import MultiChart
from stellium.core.multiwheel import MultiWheel
from stellium.core.synthesis import SynthesisChart
# Get theme colors
style = renderer.style
planet_style = style.get("planets", {})
name_color = planet_style.get("glyph_color", "#222222")
info_color = planet_style.get("info_color", "#333333")
# Header renders at the TOP of the canvas, not relative to wheel position
# Use a fixed margin for the header area
margin = renderer.size * 0.03
# Header spans the full wheel width, positioned at top of canvas
# Note: x_offset accounts for extended canvas, but header should align with wheel
x_offset = getattr(renderer, "x_offset", 0)
header_left = x_offset + margin
header_right = x_offset + renderer.size - margin
header_top = margin # Start at top of canvas, not offset by wheel position!
header_width = header_right - header_left
# Dispatch to appropriate renderer based on chart type
if isinstance(chart, SynthesisChart):
self._render_synthesis_header(
dwg,
chart,
header_left,
header_right,
header_top,
header_width,
name_color,
info_color,
renderer,
)
elif isinstance(chart, MultiChart):
# MultiChart uses the same header rendering as MultiWheel
self._render_multiwheel_header(
dwg,
chart,
header_left,
header_right,
header_top,
header_width,
name_color,
info_color,
renderer,
)
elif isinstance(chart, MultiWheel):
# For multiwheel, render using innermost chart's info
self._render_multiwheel_header(
dwg,
chart,
header_left,
header_right,
header_top,
header_width,
name_color,
info_color,
renderer,
)
elif isinstance(chart, Comparison):
self._render_comparison_header(
dwg,
chart,
header_left,
header_right,
header_top,
header_width,
name_color,
info_color,
renderer,
)
else:
self._render_single_header(
dwg,
chart,
header_left,
header_right,
header_top,
header_width,
name_color,
info_color,
renderer,
)
def _parse_location_name(self, location_name: str) -> tuple[str, str | None]:
"""
Parse a geopy location string into a short name and country.
Args:
location_name: Full location string like "Palo Alto, Santa Clara County, California, United States of America"
Returns:
Tuple of (short_name, country) where short_name is "City, State/Region"
and country is the last part (or None if it looks like USA)
"""
if not location_name:
return ("", None)
parts = [p.strip() for p in location_name.split(",")]
if len(parts) <= 2:
# Already short enough
return (location_name, None)
# First part is usually city
city = parts[0]
# Last part is usually country
country = parts[-1]
# Try to find state/region (usually second-to-last or third-to-last)
# Skip things like "County" parts
region = None
for part in reversed(parts[1:-1]):
if "county" not in part.lower():
region = part
break
# Build short name
if region:
short_name = f"{city}, {region}"
else:
short_name = city
# Skip country for common cases
skip_countries = ["United States of America", "United States", "USA", "US"]
if country in skip_countries:
country = None
return (short_name, country)
def _render_single_header(
self,
dwg,
chart,
left: float,
right: float,
top: float,
width: float,
name_color: str,
info_color: str,
renderer,
) -> None:
"""Render header for a single natal chart."""
# Get native info
name = chart.metadata.get("name") if hasattr(chart, "metadata") else None
current_y = top
# Name (big, italic-semibold, Baskerville)
if name:
dwg.add(
dwg.text(
name,
insert=(left, current_y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.name_font_size,
fill=name_color,
font_family=self.name_font_family,
font_weight=self.name_font_weight,
font_style=self.name_font_style,
)
)
current_y += int(float(self.name_font_size[:-2]) * 1.3)
# Line 2: Location (short) + coordinates
if chart.location:
location_name = getattr(chart.location, "name", None)
short_name, country = self._parse_location_name(location_name)
# Build location line with coordinates
lat = chart.location.latitude
lon = chart.location.longitude
lat_dir = "N" if lat >= 0 else "S"
lon_dir = "E" if lon >= 0 else "W"
coord_str = f"({abs(lat):.{self.coord_precision}f}°{lat_dir}, {abs(lon):.{self.coord_precision}f}°{lon_dir})"
if short_name:
location_line = f"{short_name} · {coord_str}"
else:
location_line = coord_str
dwg.add(
dwg.text(
location_line,
insert=(left, current_y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.details_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
current_y += self.line_height
# Line 3: Datetime + timezone
datetime_parts = []
if chart.datetime:
is_unknown_time = isinstance(chart, UnknownTimeChart)
if is_unknown_time:
if chart.datetime.local_datetime:
dt_str = chart.datetime.local_datetime.strftime("%b %d, %Y")
else:
dt_str = chart.datetime.utc_datetime.strftime("%b %d, %Y")
dt_str += " (Time Unknown)"
elif 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")
datetime_parts.append(dt_str)
# Add timezone + UTC offset
if chart.location:
timezone = getattr(chart.location, "timezone", None)
if timezone:
tz_str = timezone
if chart.datetime and chart.datetime.local_datetime:
try:
utc_offset = chart.datetime.local_datetime.strftime("%z")
if utc_offset:
sign = utc_offset[0]
hours = int(utc_offset[1:3])
minutes = int(utc_offset[3:5])
if minutes:
offset_str = f"UTC{sign}{hours}:{minutes:02d}"
else:
offset_str = f"UTC{sign}{hours}"
tz_str = f"{timezone} ({offset_str})"
except Exception:
pass
datetime_parts.append(tz_str)
if datetime_parts:
datetime_line = " · ".join(datetime_parts)
dwg.add(
dwg.text(
datetime_line,
insert=(left, current_y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.details_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
def _render_comparison_header(
self,
dwg,
chart,
left: float,
right: float,
top: float,
width: float,
name_color: str,
info_color: str,
renderer,
) -> None:
"""Render two-column header for comparison/biwheel chart."""
# Calculate column boundaries with padding in the middle
# Each column gets ~45% of width, with 10% gap in the middle
col_width = width * 0.45
left_col_right = left + col_width
right_col_left = right - col_width
# Left column: chart1 (inner wheel) - left aligned
self._render_chart_column(
dwg,
chart.chart1,
left,
left_col_right,
top,
"start",
name_color,
info_color,
renderer,
)
# Right column: chart2 (outer wheel) - right aligned
self._render_chart_column(
dwg,
chart.chart2,
right_col_left,
right,
top,
"end",
name_color,
info_color,
renderer,
)
def _render_chart_column(
self,
dwg,
chart,
col_left: float,
col_right: float,
top: float,
anchor: str,
name_color: str,
info_color: str,
renderer,
) -> None:
"""Render a single column of chart info (used for biwheel headers)."""
current_y = top
# Determine x position based on anchor
x = col_left if anchor == "start" else col_right
# Name
name = chart.metadata.get("name") if hasattr(chart, "metadata") else None
if name:
dwg.add(
dwg.text(
name,
insert=(x, current_y),
text_anchor=anchor,
dominant_baseline="hanging",
font_size=self.name_font_size,
fill=name_color,
font_family=self.name_font_family,
font_weight=self.name_font_weight,
font_style=self.name_font_style,
)
)
current_y += int(float(self.name_font_size[:-2]) * 1.3)
# Location (short name only)
if chart.location:
location_name = getattr(chart.location, "name", None)
short_name, _ = self._parse_location_name(location_name)
if short_name:
dwg.add(
dwg.text(
short_name,
insert=(x, current_y),
text_anchor=anchor,
dominant_baseline="hanging",
font_size=self.details_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
current_y += self.line_height
# Date/time
if chart.datetime:
is_unknown_time = isinstance(chart, UnknownTimeChart)
if is_unknown_time:
if chart.datetime.local_datetime:
dt_str = chart.datetime.local_datetime.strftime("%b %d, %Y")
else:
dt_str = chart.datetime.utc_datetime.strftime("%b %d, %Y")
dt_str += " (Time Unknown)"
elif 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")
dwg.add(
dwg.text(
dt_str,
insert=(x, current_y),
text_anchor=anchor,
dominant_baseline="hanging",
font_size=self.details_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
def _render_multiwheel_header(
self,
dwg,
chart, # MultiWheel
left: float,
right: float,
top: float,
width: float,
name_color: str,
info_color: str,
renderer,
) -> None:
"""Render header for multiwheel chart.
For 2 charts: Side-by-side layout like comparison charts
For 3-4 charts: Horizontal compact layout with all chart info
"""
chart_count = chart.chart_count
if chart_count == 2:
# Use side-by-side layout like comparison charts
col_width = width / 2
right_col_left = left + col_width
# Left column: chart1 (inner wheel)
self._render_chart_column(
dwg,
chart.charts[0],
left,
left + col_width - 10,
top,
"start",
name_color,
info_color,
renderer,
)
# Right column: chart2 (outer wheel) - right aligned
self._render_chart_column(
dwg,
chart.charts[1],
right_col_left,
right,
top,
"end",
name_color,
info_color,
renderer,
)
else:
# For 3-4 charts: compact horizontal layout
self._render_multiwheel_compact_header(
dwg,
chart,
left,
right,
top,
width,
name_color,
info_color,
renderer,
)
def _render_multiwheel_compact_header(
self,
dwg,
chart, # MultiWheel
left: float,
right: float,
top: float,
width: float,
name_color: str,
info_color: str,
renderer,
) -> None:
"""Render compact header for 3-4 chart multiwheels.
Shows each chart's label and date in a horizontal row.
"""
current_y = top
chart_count = chart.chart_count
# Calculate column width for each chart
col_width = width / chart_count
small_font_size = "11px"
for i, inner_chart in enumerate(chart.charts):
col_left = left + (i * col_width)
col_center = col_left + (col_width / 2)
# Get label (from multiwheel labels or chart metadata)
if chart.labels and i < len(chart.labels):
label = chart.labels[i]
else:
name = (
inner_chart.metadata.get("name")
if hasattr(inner_chart, "metadata")
else None
)
label = name or f"Chart {i + 1}"
# Chart label (bold, centered in column)
dwg.add(
dwg.text(
label,
insert=(col_center, current_y),
text_anchor="middle",
dominant_baseline="hanging",
font_size="14px",
fill=name_color,
font_family=self.name_font_family,
font_weight="600",
font_style=self.name_font_style,
)
)
# Second row: locations
current_y += 18
for i, inner_chart in enumerate(chart.charts):
col_left = left + (i * col_width)
col_center = col_left + (col_width / 2)
if inner_chart.location:
short_name, _ = self._parse_location_name(inner_chart.location.name)
if short_name:
dwg.add(
dwg.text(
short_name,
insert=(col_center, current_y),
text_anchor="middle",
dominant_baseline="hanging",
font_size=small_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
# Third row: dates with times
current_y += 14
for i, inner_chart in enumerate(chart.charts):
col_left = left + (i * col_width)
col_center = col_left + (col_width / 2)
if inner_chart.datetime:
is_unknown_time = isinstance(inner_chart, UnknownTimeChart)
if is_unknown_time:
if inner_chart.datetime.local_datetime:
dt_str = inner_chart.datetime.local_datetime.strftime(
"%b %d, %Y"
)
else:
dt_str = inner_chart.datetime.utc_datetime.strftime("%b %d, %Y")
dt_str += " (Unknown)"
elif inner_chart.datetime.local_datetime:
dt_str = inner_chart.datetime.local_datetime.strftime(
"%b %d, %Y %I:%M %p"
)
else:
dt_str = inner_chart.datetime.utc_datetime.strftime(
"%b %d, %Y %H:%M UTC"
)
dwg.add(
dwg.text(
dt_str,
insert=(col_center, current_y),
text_anchor="middle",
dominant_baseline="hanging",
font_size=small_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
def _render_synthesis_header(
self,
dwg,
chart,
left: float,
right: float,
top: float,
width: float,
name_color: str,
info_color: str,
renderer,
) -> None:
"""Render header for synthesis (composite/davison) chart."""
current_y = top
# Get synthesis type and labels
synthesis_method = getattr(chart, "synthesis_method", "Composite")
label1 = getattr(chart, "chart1_label", None)
label2 = getattr(chart, "chart2_label", None)
# Capitalize synthesis method for display
method_display = synthesis_method.title() if synthesis_method else "Synthesis"
# Title: "Composite: Alice & Bob" or "Davison: Alice & Bob"
# Skip default labels like "Chart 1" and "Chart 2"
if label1 and label2 and label1 != "Chart 1" and label2 != "Chart 2":
title = f"{method_display}: {label1} & {label2}"
else:
title = f"{method_display} Chart"
dwg.add(
dwg.text(
title,
insert=(left, current_y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.name_font_size,
fill=name_color,
font_family=self.name_font_family,
font_weight=self.name_font_weight,
font_style=self.name_font_style,
)
)
current_y += int(float(self.name_font_size[:-2]) * 1.3)
# Midpoint location line
if chart.location:
lat = chart.location.latitude
lon = chart.location.longitude
lat_dir = "N" if lat >= 0 else "S"
lon_dir = "E" if lon >= 0 else "W"
coord_str = f"{abs(lat):.{self.coord_precision}f}°{lat_dir}, {abs(lon):.{self.coord_precision}f}°{lon_dir}"
# For midpoint charts, just show coordinates (the "name" is usually just raw coords anyway)
location_line = f"Midpoint: {coord_str}"
dwg.add(
dwg.text(
location_line,
insert=(left, current_y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.details_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
current_y += self.line_height
# Datetime line (for Davison charts especially)
if chart.datetime and chart.datetime.local_datetime:
dt_str = chart.datetime.local_datetime.strftime("%b %d, %Y %I:%M %p")
dwg.add(
dwg.text(
dt_str,
insert=(left, current_y),
text_anchor="start",
dominant_baseline="hanging",
font_size=self.details_font_size,
fill=info_color,
font_family=renderer.style["font_family_text"],
)
)
[docs]
class RingBoundaryLayer:
"""
Renders circular boundary lines between chart rings in a multiwheel chart.
Draws circles at the boundaries between:
- Each chart ring (chart1_ring_outer, chart2_ring_outer, etc.)
- The outermost chart and the zodiac ring (zodiac_ring_inner)
Uses the theme's ring_border styling for color and width.
"""
def __init__(
self,
chart_count: int = 2,
style_override: dict[str, Any] | None = None,
) -> None:
"""
Args:
chart_count: Number of charts in the multiwheel (2, 3, or 4)
style_override: Optional style overrides for border color/width
"""
self.chart_count = chart_count
self.style = style_override or {}
[docs]
def render(self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart) -> None:
"""Render ring boundary circles."""
# Get ring border styling from theme (with fallbacks)
style = renderer.style.get("ring_border", {})
style = {**style, **self.style} # Apply overrides
# Use houses line color as default (matches house cusp lines)
default_color = renderer.style.get("houses", {}).get(
"line_color", renderer.style.get("border_color", "#CCCCCC")
)
border_color = style.get("color", default_color)
border_width = style.get("width", 1.0)
# Collect the radii where we need to draw boundaries (using set to avoid duplicates)
boundary_radii = set()
# Add boundary at each chart ring's outer edge
for chart_num in range(1, self.chart_count + 1):
ring_outer_key = f"chart{chart_num}_ring_outer"
if ring_outer_key in renderer.radii:
boundary_radii.add(renderer.radii[ring_outer_key])
# Add boundary at zodiac ring inner edge (between outermost chart and zodiac)
if "zodiac_ring_inner" in renderer.radii:
boundary_radii.add(renderer.radii["zodiac_ring_inner"])
# Draw circular boundaries
# Center coordinates account for any canvas offsets
cx = renderer.x_offset + renderer.center
cy = renderer.y_offset + renderer.center
for radius in boundary_radii:
dwg.add(
dwg.circle(
center=(cx, cy),
r=radius,
fill="none",
stroke=border_color,
stroke_width=border_width,
)
)
[docs]
class OuterBorderLayer:
"""Renders the outer containment border for comparison/biwheel charts."""
[docs]
def render(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart: Any
) -> None:
"""Render the outer containment border using config radius and style."""
# Check if outer_containment_border radius is set
if "outer_containment_border" not in renderer.radii:
return
border_radius = renderer.radii["outer_containment_border"]
# Use border styling from theme
border_color = renderer.style.get("border_color", "#999999")
border_width = renderer.style.get("border_width", 1)
# Draw the outer border circle
dwg.add(
dwg.circle(
center=(
renderer.center + renderer.x_offset,
renderer.center + renderer.y_offset,
),
r=border_radius,
fill="none",
stroke=border_color,
stroke_width=border_width,
)
)