Source code for stellium.visualization.moon_phase

"""
Moon phase visualization layer.

Renders accurate moon phase symbols showing the illuminated portion
with curved terminator lines.
"""

from typing import Any

import svgwrite

from stellium.core.models import (
    CalculatedChart,
    CelestialPosition,
    ObjectType,
    PhaseData,
)

from .core import ChartRenderer


[docs] class MoonPhaseLayer: """ Renders the moon phase on the chart. This layer draws an accurate representation of the moon's current phase using curved terminator lines to show the illuminated portion. The moon can be positioned in the center or in any corner, and can optionally display the phase name as a text label. """ DEFAULT_STYLE = { "size": 40, # Radius in pixels (auto-sized: 60 for center, 28 for corners) "border_color": "#2C3E50", "border_width": 2, "lit_color": "#F8F9FA", "shadow_color": "#2C3E50", "opacity": 0.95, "label_color": "#2C3E50", "label_size": "11px", # Auto-sized: 14px for center, 11px for corners to match corner text "label_offset": 10, # Pixels from moon symbol (above for upper corners, below for others) } def __init__( self, position: str | None = None, # None = auto-detect based on chart content show_label: bool = True, style_override: dict[str, Any] | None = None, ) -> None: """ Initialize moon phase layer. Args: position: Where to place the moon phase symbol. Options: "center", "top-left", "top-right", "bottom-left", "bottom-right", None If None (default), automatically chooses: - "bottom-right" if chart has aspects (keeps center clear) - "center" if chart has no aspects (makes use of empty space) show_label: Whether to display the phase name below the moon style_override: Optional style overrides """ valid_positions = [ "center", "top-left", "top-right", "bottom-left", "bottom-right", None, ] if position not in valid_positions: raise ValueError( f"Invalid position: {position}. Must be one of {valid_positions}" ) self.position = position # None means auto-detect later in render() self.show_label = show_label self.style = {**self.DEFAULT_STYLE, **(style_override or {})} # Auto-size moon and label based on position if not explicitly overridden if style_override is None or "size" not in style_override: if position == "center": self.style["size"] = 60 # Larger for center else: self.style["size"] = 28 # Smaller for corners if style_override is None or "label_size" not in style_override: if position == "center": self.style["label_size"] = "14px" else: self.style["label_size"] = "11px" # Match corner text size
[docs] def render( self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart: CalculatedChart, ) -> None: """ Render the moon phase. Args: renderer: ChartRenderer instance dwg: SVG drawing object chart: Calculated chart (or MultiWheel - uses innermost chart) """ from stellium.core.multiwheel import MultiWheel # Handle MultiWheel: use innermost chart actual_chart = chart.charts[0] if isinstance(chart, MultiWheel) else chart # Find the Moon moon = actual_chart.get_object("Moon") if not moon or not moon.phase: return # Auto-detect position if not explicitly set if self.position is None: # Smart default: bottom-right if aspects present, center if not if actual_chart.aspects and len(actual_chart.aspects) > 0: actual_position = "bottom-right" else: actual_position = "center" else: actual_position = self.position # Auto-size based on detected position if actual_position == "center": self.style["size"] = self.style.get("size", 60) self.style["label_size"] = self.style.get("label_size", "14px") else: self.style["size"] = self.style.get("size", 28) self.style["label_size"] = self.style.get("label_size", "11px") # Temporarily set position for coordinate calculation original_position = self.position self.position = actual_position # Access the phase data cleanly phase_data = moon.phase # Create moon phase symbol moon_group = self._create_moon_phase_symbol( dwg, phase_data.phase_angle, phase_data.illuminated_fraction, self.style["size"], self.style["border_color"], self.style["border_width"], self.style["lit_color"], self.style["shadow_color"], self.style["opacity"], ) if moon_group: # Calculate position based on position setting x, y = self._get_position_coordinates(renderer) # Position the moon positioned_group = dwg.g(transform=f"translate({x}, {y})") for element in moon_group.elements: positioned_group.add(element) dwg.add(positioned_group) # Add label if requested if self.show_label: # Place label above moon for upper corners, below for others # Ensure label has enough padding from edge (minimum margin) min_margin = renderer.size * 0.03 # Match chart padding # Get y_offset to account for header y_offset = getattr(renderer, "y_offset", 0) if self.position in ["top-left", "top-right"]: # Above the moon - ensure we don't hit top edge label_y = max( y - self.style["size"] - self.style["label_offset"], y_offset + min_margin + 12, # 12px for text height ) dominant_baseline = "auto" # Bottom of text aligns with y else: # Below the moon - ensure we don't hit bottom edge # y_offset + renderer.size is the bottom of the wheel area label_y = min( y + self.style["size"] + self.style["label_offset"], y_offset + renderer.size - min_margin - 4, # 4px buffer ) dominant_baseline = "hanging" # Top of text aligns with y # Get theme-aware label color from planets info_color (match corner text) from .palettes import adjust_color_for_contrast theme_text_color = renderer.style.get("planets", {}).get( "info_color", self.style["label_color"] ) background_color = renderer.style.get("background_color", "#FFFFFF") label_color = adjust_color_for_contrast( theme_text_color, background_color, min_contrast=4.5 ) dwg.add( dwg.text( phase_data.phase_name, insert=(x, label_y), text_anchor="middle", dominant_baseline=dominant_baseline, font_size=self.style["label_size"], fill=label_color, font_family=renderer.style["font_family_text"], font_weight="normal", # Match corner text (not bold) ) ) # Restore original position self.position = original_position
def _get_position_coordinates(self, renderer: ChartRenderer) -> tuple[float, float]: """ Calculate the (x, y) coordinates for moon placement based on position setting. Args: renderer: ChartRenderer instance Returns: Tuple of (x, y) coordinates """ # Match chart padding for corner placement margin = renderer.size * 0.01 # Get offsets for extended canvas positioning x_offset = getattr(renderer, "x_offset", 0) y_offset = getattr(renderer, "y_offset", 0) # For corner placement, add moon size + padding for proper inset if self.position != "center": # Use configured size from style moon_radius = self.style["size"] corner_inset = margin + moon_radius # For bottom corners with labels, add extra vertical space # to prevent label collision with bottom edge if self.show_label and self.position in ["bottom-left", "bottom-right"]: # Extract font size (e.g., "11px" -> 11) label_height = int(float(self.style["label_size"][:-2])) # Add label height + offset + small buffer to move moon up extra_spacing = label_height + self.style["label_offset"] bottom_inset = corner_inset + extra_spacing else: bottom_inset = corner_inset if self.position == "top-left": return (x_offset + corner_inset, y_offset + corner_inset) elif self.position == "top-right": return ( x_offset + renderer.size - corner_inset, y_offset + corner_inset, ) elif self.position == "bottom-left": return ( x_offset + corner_inset + 15, y_offset + renderer.size - bottom_inset, ) elif self.position == "bottom-right": return ( x_offset + renderer.size - corner_inset - 15, y_offset + renderer.size - bottom_inset, ) # Center position return (x_offset + renderer.center, y_offset + renderer.center) def _create_moon_phase_symbol( self, dwg: svgwrite.Drawing, phase_angle: float, illuminated_fraction: float, radius: float, border_color: str, border_width: float, lit_color: str, shadow_color: str, opacity: float, ) -> svgwrite.container.Group: """ Create an SVG group containing accurate moon phase visualization. Args: dwg: SVG drawing object moon: Moon position with phase data radius: Moon radius border_color: Border color border_width: Border width lit_color: Color for illuminated portion shadow_color: Color for shadowed portion opacity: Overall opacity Returns: SVG group containing moon phase """ # Determine if waxing or waning waxing = self._is_moon_waxing(phase_angle) # Create group group = dwg.g() # Handle special cases if illuminated_fraction <= 0.01: # New moon - completely dark group.add( dwg.circle( center=(0, 0), r=radius, fill=shadow_color, stroke=border_color, stroke_width=border_width, opacity=opacity, ) ) return group elif illuminated_fraction >= 0.99: # Full moon - completely lit group.add( dwg.circle( center=(0, 0), r=radius, fill=lit_color, stroke=border_color, stroke_width=border_width, opacity=opacity, ) ) return group # Start with base circle (shadow) group.add( dwg.circle( center=(0, 0), r=radius, fill=shadow_color, stroke="none", opacity=opacity, ) ) # Calculate and draw the terminator if abs(illuminated_fraction - 0.5) < 0.001: # Quarter moon - exactly half lit if waxing: # First quarter - right half lit path_d = f"M 0 {-radius} A {radius} {radius} 0 0 1 0 {radius} Z" else: # Last quarter - left half lit path_d = f"M 0 {-radius} A {radius} {radius} 0 0 0 0 {radius} Z" group.add( dwg.path(d=path_d, fill=lit_color, stroke="none", opacity=opacity) ) else: # Crescent or gibbous - curved terminator terminator_width = abs(2 * (illuminated_fraction - 0.5)) * radius if illuminated_fraction < 0.5: # Crescent phase if waxing: path_d = self._create_crescent_path(radius, terminator_width, True) else: path_d = self._create_crescent_path(radius, terminator_width, False) group.add( dwg.path(d=path_d, fill=lit_color, stroke="none", opacity=opacity) ) else: # Gibbous phase - fill with lit, add shadow crescent group.add( dwg.circle( center=(0, 0), r=radius, fill=lit_color, stroke="none", opacity=opacity, ) ) if waxing: path_d = self._create_crescent_path(radius, terminator_width, False) else: path_d = self._create_crescent_path(radius, terminator_width, True) group.add( dwg.path( d=path_d, fill=shadow_color, stroke="none", opacity=opacity ) ) # Add border group.add( dwg.circle( center=(0, 0), r=radius, fill="none", stroke=border_color, stroke_width=border_width, opacity=opacity, ) ) return group def _is_moon_waxing(self, phase_angle: float) -> bool: """ Determine if moon is waxing based on phase angle. Args: phase_angle: Phase angle in degrees Returns: True if waxing, False if waning """ normalized_angle = phase_angle % 360 return normalized_angle <= 180 def _create_crescent_path( self, radius: float, terminator_width: float, on_right: bool ) -> str: """ Create SVG path for crescent shape with elliptical terminator. Args: radius: Moon radius terminator_width: Width of terminator ellipse on_right: True if crescent on right, False if on left Returns: SVG path string """ if on_right: # Crescent on right side path = f"M 0 {-radius} " path += f"A {radius} {radius} 0 0 1 0 {radius} " path += f"A {terminator_width} {radius} 0 0 0 0 {-radius} " path += "Z" else: # Crescent on left side path = f"M 0 {-radius} " path += f"A {radius} {radius} 0 0 0 0 {radius} " path += f"A {terminator_width} {radius} 0 0 1 0 {-radius} " path += "Z" return path
[docs] def draw_moon_phase_standalone( phase_frac: float, phase_angle: float, filename: str = "moon_phase.svg", size: int = 200, style: dict[str, Any] | None = None, ) -> str: """ Draw a standalone moon phase SVG. Useful for testing or standalone moon phase displays. Args: phase_frac: Illuminated fraction (0-1) phase_angle: Phase angle in degrees (0-360) filename: Output filename size: SVG size in pixels style: Style overrides Returns: Filename of saved SVG Example: # Draw a waxing crescent draw_moon_phase_standalone(0.25, 90, "waxing_crescent.svg") # Draw a full moon draw_moon_phase_standalone(1.0, 180, "full_moon.svg") """ moon = CelestialPosition( name="Moon", object_type=ObjectType.PLANET, longitude=0.0, ) moon_phase_data = PhaseData( phase_angle=phase_angle, illuminated_fraction=phase_frac, elongation=0.0, apparent_diameter=0.0, apparent_magnitude=0.0, ) object.__setattr__(moon, "phase", moon_phase_data) # Create SVG dwg = svgwrite.Drawing( filename=filename, size=(f"{size}px", f"{size}px"), viewBox=f"0 0 {size} {size}", ) # Add background dwg.add(dwg.rect(insert=(0, 0), size=(size, size), fill="#1a1a1a")) # Create moon phase layer layer = MoonPhaseLayer(style_override=style) # Render (need a mock renderer/chart for the interface) from unittest.mock import Mock mock_renderer = Mock() mock_renderer.center = size // 2 mock_chart = Mock() mock_chart.get_object = lambda name: moon if name == "Moon" else None layer.render(mock_renderer, dwg, mock_chart) dwg.save() return filename