Source code for stellium.visualization.dial.renderer

"""
Dial Chart Renderer (stellium.visualization.dial.renderer)

Core coordinate system and rendering context for dial charts.
Similar to ChartRenderer but with longitude compression for 90°/45°/360° dials.
"""

import math
from typing import Any

import svgwrite

from stellium.visualization.dial.config import DialConfig


[docs] class DialRenderer: """ Core renderer for dial chart visualization. Provides coordinate transformation for compressed dial charts (90°, 45°, or 360°). The dial compresses the zodiac so that hard aspects appear as conjunctions. For a 90° dial: - 0° Aries, Cancer, Libra, Capricorn all map to 0° on the dial - 0° Taurus, Leo, Scorpio, Aquarius all map to 30° on the dial - 0° Gemini, Virgo, Sagittarius, Pisces all map to 60° on the dial The coordinate system places 0° at the top (12 o'clock) and progresses clockwise. """ def __init__(self, config: DialConfig): """ Initialize the dial renderer. Args: config: DialConfig with all dial settings and theme """ self.config = config self.dial_degrees = config.dial_degrees self.size = config.size self.rotation = config.rotation # Header support: when header is enabled, canvas is taller # and dial center is offset down self.header_height = config.header.height if config.show_header else 0 self.canvas_height = config.size + self.header_height # Center is in the middle of the dial area (below header) self.center = config.size // 2 self.center_y = self.header_height + self.center # Get radii in absolute pixels self.radii = config.radii.to_absolute(config.size) # Get theme-derived style self.style = config.get_dial_style()
[docs] def compress_longitude(self, longitude: float) -> float: """ Compress 360° zodiac longitude to dial degrees. Args: longitude: Zodiac longitude (0-360°) Returns: Compressed dial position (0 to dial_degrees) Examples: For 90° dial: - 0° (Aries) → 0° - 90° (Cancer) → 0° - 180° (Libra) → 0° - 270° (Capricorn) → 0° - 45° (mid-Taurus) → 45° - 135° (mid-Leo) → 45° """ return longitude % self.dial_degrees
[docs] def dial_to_svg_angle(self, dial_deg: float) -> float: """ Convert dial degrees to SVG angle. SVG coordinate system: - 0° = 3 o'clock (right) - 90° = 6 o'clock (bottom) - Angles increase clockwise Dial coordinate system: - 0° = 12 o'clock (top) - Angles increase clockwise - Rotation shifts where 0° appears Args: dial_deg: Position on the dial (0 to dial_degrees) Returns: SVG angle in degrees (0-360) """ # Apply rotation rotated = (dial_deg - self.rotation) % self.dial_degrees # Scale to 360° for SVG (e.g., 90° dial → multiply by 4) scale_factor = 360 / self.dial_degrees scaled = rotated * scale_factor # Convert from dial coordinates (0° at top) to SVG (0° at right) # Dial 0° (top) = SVG 270° # Dial 90° (right on 360° dial) = SVG 0° svg_angle = (scaled + 270) % 360 return svg_angle
[docs] def polar_to_cartesian(self, dial_deg: float, radius: float) -> tuple[float, float]: """ Convert dial degree and radius to (x, y) cartesian coordinates. Args: dial_deg: Position on the dial (0 to dial_degrees, or any value which will be taken modulo dial_degrees) radius: Distance from center in pixels Returns: Tuple of (x, y) coordinates """ svg_angle = self.dial_to_svg_angle(dial_deg) svg_angle_rad = math.radians(svg_angle) # SVG y-axis is inverted (positive = down) # Use center for x, center_y for y (accounts for header offset) x = self.center + radius * math.cos(svg_angle_rad) y = self.center_y + radius * math.sin(svg_angle_rad) return x, y
[docs] def longitude_to_cartesian( self, longitude: float, radius: float ) -> tuple[float, float]: """ Convert zodiac longitude directly to cartesian coordinates. Convenience method that compresses longitude and converts to cartesian in one step. Args: longitude: Zodiac longitude (0-360°) radius: Distance from center in pixels Returns: Tuple of (x, y) coordinates """ dial_deg = self.compress_longitude(longitude) return self.polar_to_cartesian(dial_deg, radius)
[docs] def create_drawing(self) -> svgwrite.Drawing: """ Create the SVG drawing with background. Returns: svgwrite.Drawing ready for layers to render into """ dwg = svgwrite.Drawing( filename=self.config.filename, size=(f"{self.size}px", f"{self.canvas_height}px"), viewBox=f"0 0 {self.size} {self.canvas_height}", profile="full", ) # Add background dwg.add( dwg.rect( insert=(0, 0), size=(f"{self.size}px", f"{self.canvas_height}px"), fill=self.style.background_color, ) ) return dwg
[docs] def draw_arc( self, dwg: svgwrite.Drawing, start_deg: float, end_deg: float, radius: float, **kwargs: Any, ) -> svgwrite.path.Path: """ Draw an arc on the dial. Args: dwg: SVG drawing start_deg: Start position in dial degrees end_deg: End position in dial degrees radius: Radius of the arc **kwargs: Additional SVG path attributes (stroke, fill, etc.) Returns: SVG path element """ x1, y1 = self.polar_to_cartesian(start_deg, radius) x2, y2 = self.polar_to_cartesian(end_deg, radius) # Determine if this is a large arc (> 180° in SVG terms) arc_span = (end_deg - start_deg) % self.dial_degrees svg_span = arc_span * (360 / self.dial_degrees) large_arc = 1 if svg_span > 180 else 0 # SVG arc: A rx ry x-axis-rotation large-arc-flag sweep-flag x y # sweep-flag=1 for clockwise d = f"M {x1} {y1} A {radius} {radius} 0 {large_arc} 1 {x2} {y2}" path = dwg.path(d=d, **kwargs) return path
[docs] def draw_line_radial( self, dwg: svgwrite.Drawing, dial_deg: float, inner_radius: float, outer_radius: float, **kwargs: Any, ) -> svgwrite.shapes.Line: """ Draw a radial line from inner to outer radius at a given dial degree. Args: dwg: SVG drawing dial_deg: Position in dial degrees inner_radius: Inner radius in pixels outer_radius: Outer radius in pixels **kwargs: Additional SVG line attributes Returns: SVG line element """ x1, y1 = self.polar_to_cartesian(dial_deg, inner_radius) x2, y2 = self.polar_to_cartesian(dial_deg, outer_radius) line = dwg.line(start=(x1, y1), end=(x2, y2), **kwargs) return line
[docs] def draw_circle( self, dwg: svgwrite.Drawing, radius: float, **kwargs: Any, ) -> svgwrite.shapes.Circle: """ Draw a circle centered on the dial. Args: dwg: SVG drawing radius: Circle radius in pixels **kwargs: Additional SVG circle attributes Returns: SVG circle element """ circle = dwg.circle(center=(self.center, self.center_y), r=radius, **kwargs) return circle
[docs] def get_cardinal_points(self) -> list[float]: """ Get the cardinal point positions for this dial size. For 90° dial: 0°, 22.5°, 45°, 67.5° For 45° dial: 0°, 11.25°, 22.5°, 33.75° For 360° dial: 0°, 90°, 180°, 270° Returns: List of dial degrees for cardinal points """ # Cardinal points divide the dial into 4 equal parts quarter = self.dial_degrees / 4 return [0, quarter, quarter * 2, quarter * 3]
[docs] def get_modality_sectors(self) -> list[tuple[float, float, str]]: """ Get the modality sector definitions for this dial. For 90° dial, each 30° sector represents one modality: - 0°-30°: Cardinal (Aries, Cancer, Libra, Capricorn) - 30°-60°: Fixed (Taurus, Leo, Scorpio, Aquarius) - 60°-90°: Mutable (Gemini, Virgo, Sagittarius, Pisces) Returns: List of (start_deg, end_deg, modality_name) tuples """ sector_size = self.dial_degrees / 3 return [ (0, sector_size, "Cardinal"), (sector_size, sector_size * 2, "Fixed"), (sector_size * 2, self.dial_degrees, "Mutable"), ]