"""
Planet layers - planet glyphs, positions, and moon range.
"""
from typing import Any
import svgwrite
from stellium.core.models import (
CalculatedChart,
CelestialPosition,
UnknownTimeChart,
)
from stellium.visualization.core import (
ZODIAC_GLYPHS,
ChartRenderer,
embed_svg_glyph,
get_glyph,
)
from stellium.visualization.palettes import (
PlanetGlyphPalette,
ZodiacPalette,
get_planet_glyph_color,
get_sign_info_color,
)
__all__ = ["PlanetLayer", "MoonRangeLayer"]
[docs]
class PlanetLayer:
"""Renders a set of planets at a specific radius.
For multiwheel charts, use wheel_index to specify which chart ring to render:
- wheel_index=0: Chart 1 (innermost)
- wheel_index=1: Chart 2
- wheel_index=2: Chart 3
- wheel_index=3: Chart 4 (outermost, just inside zodiac)
The info_mode parameter controls how much detail to show:
- "full": Degree + sign glyph + minutes (default for single charts)
- "compact": Degree only, e.g. "15°" (good for multiwheel)
- "no_sign": Degree + minutes, no sign glyph, e.g. "15°32'"
- "none": No info stack, glyph only
"""
def __init__(
self,
planet_set: list[CelestialPosition],
radius_key: str = "planet_ring",
style_override: dict[str, Any] | None = None,
use_outer_wheel_color: bool = False,
info_stack_direction: str = "inward",
show_info_stack: bool = True,
show_position_ticks: bool = False,
wheel_index: int = 0,
info_mode: str = "full",
info_stack_distance: float = 0.8,
glyph_size_override: str | None = None,
) -> None:
"""
Args:
planet_set: The list of CelestialPosition objects to draw.
radius_key: The key from renderer.radii to use (e.g., "planet_ring").
For multiwheel, this is auto-derived from wheel_index if not specified.
style_override: Style overrides for this layer.
use_outer_wheel_color: If True, use the theme's outer_wheel_planet_color (legacy).
info_stack_direction: "inward" (toward center) or "outward" (away from center).
show_info_stack: If False, hide info stacks (glyph only). Deprecated, use info_mode.
show_position_ticks: If True, draw colored tick marks at true planet positions
on the zodiac ring inner edge.
wheel_index: Which chart ring to render (0=innermost, used for multiwheel).
info_mode: "full" (degree+sign+minutes), "compact" (degree only),
"no_sign" (degree+minutes), "none" (glyph only).
info_stack_distance: Multiplier for distance between glyph and info stack (default 0.8).
Smaller values move the info stack closer to the glyph.
glyph_size_override: If set, overrides the theme's glyph_size (e.g., "24px" for smaller).
"""
self.planets = planet_set
self.radius_key = radius_key
self.style = style_override or {}
self.use_outer_wheel_color = use_outer_wheel_color
self.info_stack_direction = info_stack_direction
self.show_info_stack = show_info_stack
self.show_position_ticks = show_position_ticks
self.wheel_index = wheel_index
self.info_mode = info_mode
self.info_stack_distance = info_stack_distance
self.glyph_size_override = glyph_size_override
[docs]
def render(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart: CalculatedChart
) -> None:
style = renderer.style["planets"].copy()
style.update(self.style)
# Determine glyph size (use override if provided)
glyph_size_str = self.glyph_size_override or style["glyph_size"]
glyph_size_px = float(glyph_size_str[:-2]) # Remove "px" suffix
# Determine radius based on wheel_index or explicit radius_key
chart_num = self.wheel_index + 1
planet_ring_key = f"chart{chart_num}_planet_ring"
# Use multiwheel radius if available, otherwise fall back to legacy
if planet_ring_key in renderer.radii:
base_radius = renderer.radii[planet_ring_key]
else:
base_radius = renderer.radii.get(
self.radius_key, renderer.radii.get("planet_ring")
)
# Calculate adjusted positions with collision detection
adjusted_positions = self._calculate_adjusted_positions(
self.planets, base_radius, glyph_size_px
)
# Determine effective info mode (handle legacy show_info_stack)
effective_info_mode = self.info_mode
if not self.show_info_stack and self.info_mode == "full":
effective_info_mode = "none" # Legacy compatibility
# Draw all planets with their info columns
for planet in self.planets:
original_long = planet.longitude
adjusted_long = adjusted_positions[planet]["longitude"]
is_adjusted = adjusted_positions[planet]["adjusted"]
# Determine glyph color using wheel_index-based colors for multiwheel
chart_color_key = f"chart{chart_num}_color"
if chart_color_key in style:
# Use multiwheel chart-specific color
base_color = style[chart_color_key]
elif self.use_outer_wheel_color and "outer_wheel_planet_color" in style:
# Legacy: use outer wheel color for comparison charts
base_color = style["outer_wheel_planet_color"]
elif renderer.planet_glyph_palette:
planet_palette = PlanetGlyphPalette(renderer.planet_glyph_palette)
base_color = get_planet_glyph_color(
planet.name, planet_palette, style["glyph_color"]
)
else:
base_color = style["glyph_color"]
# Override with retro color if retrograde
color = style["retro_color"] if planet.is_retrograde else base_color
# Draw position tick at true position, extending inward from zodiac ring
if self.show_position_ticks:
tick_radius_outer = renderer.radii["zodiac_ring_inner"]
tick_length = 6
x_tick_outer, y_tick_outer = renderer.polar_to_cartesian(
original_long, tick_radius_outer
)
x_tick_inner, y_tick_inner = renderer.polar_to_cartesian(
original_long, tick_radius_outer - tick_length
)
dwg.add(
dwg.line(
start=(x_tick_outer, y_tick_outer),
end=(x_tick_inner, y_tick_inner),
stroke=color,
stroke_width=1.5,
)
)
# Draw connector line if position was adjusted
if is_adjusted:
# Glyph is at adjusted position on planet ring
x_glyph, y_glyph = renderer.polar_to_cartesian(
adjusted_long, base_radius
)
if self.show_position_ticks:
# Connect to the position tick on zodiac ring inner edge
x_target, y_target = renderer.polar_to_cartesian(
original_long, renderer.radii["zodiac_ring_inner"]
)
else:
# Original behavior: connect to true position on planet ring
x_target, y_target = renderer.polar_to_cartesian(
original_long, base_radius
)
dwg.add(
dwg.line(
start=(x_glyph, y_glyph),
end=(x_target, y_target),
stroke="#999999",
stroke_width=0.5,
stroke_dasharray="2,2",
opacity=0.6,
)
)
# Draw planet glyph at adjusted position
glyph_info = get_glyph(planet.name)
x, y = renderer.polar_to_cartesian(adjusted_long, base_radius)
if glyph_info["type"] == "svg":
# Render inline SVG glyph (works across all browsers)
embed_svg_glyph(
dwg,
glyph_info["value"],
x,
y,
glyph_size_px,
fill_color=color,
)
else:
# Render Unicode text glyph
dwg.add(
dwg.text(
glyph_info["value"],
insert=(x, y),
text_anchor="middle",
dominant_baseline="central",
font_size=glyph_size_str,
fill=color,
font_family=renderer.style["font_family_glyphs"],
)
)
# Draw Planet Info based on info_mode
# - "full": Degree + Sign glyph + Minutes (3-row stack)
# - "compact": Degree only (single value, e.g., "15°")
# - "no_sign": Degree + Minutes (2-row stack, no sign glyph)
# - "none": No info stack
if effective_info_mode != "none":
# Calculate radii for info rings based on direction
# Use info_stack_distance multiplier (default 0.8, smaller = closer to glyph)
dist = self.info_stack_distance
if self.info_stack_direction == "outward":
# Stack extends AWAY from center (for outer wheel)
degrees_radius = base_radius + (glyph_size_px * dist)
sign_radius = base_radius + (glyph_size_px * (dist + 0.4))
# For no_sign mode, use 0.55 spacing for better readability with small glyphs
if effective_info_mode == "no_sign":
minutes_radius = base_radius + (glyph_size_px * (dist + 0.55))
else:
minutes_radius = base_radius + (glyph_size_px * (dist + 0.8))
else:
# Stack extends TOWARD center (default, for inner wheel)
degrees_radius = base_radius - (glyph_size_px * dist)
sign_radius = base_radius - (glyph_size_px * (dist + 0.4))
# For no_sign mode, use 0.55 spacing for better readability with small glyphs
if effective_info_mode == "no_sign":
minutes_radius = base_radius - (glyph_size_px * (dist + 0.55))
else:
minutes_radius = base_radius - (glyph_size_px * (dist + 0.8))
# Degrees (shown in both "full" and "compact" modes)
deg_str = f"{int(planet.sign_degree)}°"
x_deg, y_deg = renderer.polar_to_cartesian(
adjusted_long, degrees_radius
)
dwg.add(
dwg.text(
deg_str,
insert=(x_deg, y_deg),
text_anchor="middle",
dominant_baseline="central",
font_size=style["info_size"],
fill=style["info_color"],
font_family=renderer.style["font_family_text"],
)
)
# Sign glyph only in "full" mode
if effective_info_mode == "full":
# Sign glyph - with optional adaptive coloring
sign_glyph = ZODIAC_GLYPHS[int(planet.longitude // 30)]
sign_index = int(planet.longitude // 30)
x_sign, y_sign = renderer.polar_to_cartesian(
adjusted_long, sign_radius
)
# Use adaptive sign color if enabled
if renderer.color_sign_info and renderer.zodiac_palette:
zodiac_pal = ZodiacPalette(renderer.zodiac_palette)
sign_color = get_sign_info_color(
sign_index,
zodiac_pal,
renderer.style["background_color"],
min_contrast=4.5,
)
else:
sign_color = style["info_color"]
dwg.add(
dwg.text(
sign_glyph,
insert=(x_sign, y_sign),
text_anchor="middle",
dominant_baseline="central",
font_size=style["info_size"],
fill=sign_color,
font_family=renderer.style["font_family_glyphs"],
)
)
# Minutes in "full" and "no_sign" modes
if effective_info_mode in ("full", "no_sign"):
min_str = f"{int((planet.sign_degree % 1) * 60):02d}'"
x_min, y_min = renderer.polar_to_cartesian(
adjusted_long, minutes_radius
)
dwg.add(
dwg.text(
min_str,
insert=(x_min, y_min),
text_anchor="middle",
dominant_baseline="central",
font_size=style["info_size"],
fill=style["info_color"],
font_family=renderer.style["font_family_text"],
)
)
def _calculate_adjusted_positions(
self,
planets: list[CelestialPosition],
base_radius: float,
glyph_size_px: float = 32.0,
) -> dict[CelestialPosition, dict[str, Any]]:
"""
Calculate adjusted positions for planets with radius-aware collision detection.
Uses an iterative force-based algorithm that:
1. Calculates minimum angular separation based on glyph size and ring radius
2. Iteratively pushes colliding glyphs apart until stable
3. Properly handles wrap-around at the 0°/360° boundary
4. Limits maximum displacement to keep glyphs near their true positions
Args:
planets: List of planets to position
base_radius: The radius at which to place planet glyphs (in pixels)
glyph_size_px: The glyph font size in pixels (default 32.0)
Returns:
Dictionary mapping each planet to its position info:
{
planet: {
"longitude": adjusted_longitude,
"adjusted": bool (True if position was changed)
}
}
"""
import math
if not planets:
return {}
# Calculate radius-aware minimum separation
# Glyph width is approximately the font size
# We need enough angular space for the glyph plus a small buffer
glyph_width_px = glyph_size_px
buffer_factor = 1.3 # 30% extra space for visual clarity
# Arc length formula: arc = (angle/360) * 2*pi*r
# Solving for angle: angle = (arc * 360) / (2*pi*r)
circumference = 2 * math.pi * base_radius
min_separation = (glyph_width_px * buffer_factor * 360) / circumference
# Ensure a reasonable minimum (at least 4°) and maximum (at most 15°)
min_separation = max(4.0, min(15.0, min_separation))
# Initialize display positions to true positions
display_positions = {p: p.longitude for p in planets}
# Iterative force-based spreading
max_iterations = 50
convergence_threshold = 0.1 # Stop when max movement < this
for _iteration in range(max_iterations):
max_movement = 0.0
# Sort planets by current display position for efficient neighbor checks
sorted_planets = sorted(planets, key=lambda p: display_positions[p])
n = len(sorted_planets)
# Calculate forces on each planet
forces = dict.fromkeys(planets, 0.0)
# Check each adjacent pair (including wrap-around from last to first)
for i in range(n):
curr_planet = sorted_planets[i]
next_planet = sorted_planets[(i + 1) % n] # Wrap around
curr_pos = display_positions[curr_planet]
next_pos = display_positions[next_planet]
# Calculate the forward (clockwise) distance from curr to next
forward_dist = (next_pos - curr_pos) % 360
# If forward distance > 180, the "short" path is backward
# We want the short path distance for collision detection
if forward_dist > 180:
# The short path is backward (counter-clockwise)
short_dist = 360 - forward_dist
else:
# The short path is forward (clockwise)
short_dist = forward_dist
if short_dist < min_separation:
# Collision detected - push them apart
overlap = min_separation - short_dist
push = overlap * 0.5
# Determine which direction to push
# Push curr backward and next forward along the SHORT path
if forward_dist <= 180:
# Short path is forward: curr should go backward, next forward
forces[curr_planet] -= push
forces[next_planet] += push
else:
# Short path is backward: curr should go forward, next backward
forces[curr_planet] += push
forces[next_planet] -= push
# Apply forces with damping and limits
for planet in planets:
force = forces[planet]
if abs(force) > 0.01: # Only apply meaningful forces
# Limit max movement per iteration for stability
movement = max(-2.0, min(2.0, force))
# Calculate new position
new_pos = (display_positions[planet] + movement) % 360
# Limit max displacement from true position (max 20°)
true_pos = planet.longitude
displacement = self._signed_circular_distance(true_pos, new_pos)
max_displacement = 20.0
if abs(displacement) > max_displacement:
# Clamp to max displacement
if displacement > 0:
new_pos = (true_pos + max_displacement) % 360
else:
new_pos = (true_pos - max_displacement) % 360
max_movement = max(max_movement, abs(force))
display_positions[planet] = new_pos
# Check for convergence
if max_movement < convergence_threshold:
break
# Build result dictionary
adjusted_positions = {}
for planet in planets:
original_long = planet.longitude
adjusted_long = display_positions[planet]
# Check if position was actually changed (more than 0.5° difference)
angle_diff = abs(
self._signed_circular_distance(original_long, adjusted_long)
)
is_adjusted = angle_diff > 0.5
adjusted_positions[planet] = {
"longitude": adjusted_long,
"adjusted": is_adjusted,
}
return adjusted_positions
def _circular_distance(self, pos1: float, pos2: float) -> float:
"""
Calculate the shortest angular distance between two positions on a circle.
Always returns a positive value representing the absolute distance.
Args:
pos1: First position in degrees (0-360)
pos2: Second position in degrees (0-360)
Returns:
Shortest angular distance in degrees (0-180)
"""
diff = abs(pos2 - pos1)
if diff > 180:
diff = 360 - diff
return diff
def _signed_circular_distance(self, from_pos: float, to_pos: float) -> float:
"""
Calculate the signed angular distance from one position to another.
Positive = clockwise (increasing degrees), Negative = counter-clockwise.
Args:
from_pos: Starting position in degrees (0-360)
to_pos: Target position in degrees (0-360)
Returns:
Signed angular distance in degrees (-180 to +180)
"""
diff = to_pos - from_pos
# Normalize to -180 to +180
while diff > 180:
diff -= 360
while diff < -180:
diff += 360
return diff
[docs]
class MoonRangeLayer:
"""
Renders a shaded arc showing the Moon's possible position range.
Used for unknown birth time charts where the Moon could be anywhere
within a ~12-14° range throughout the day.
The arc is drawn as a semi-transparent wedge from the day-start position
to the day-end position, with the Moon glyph at the noon position.
"""
def __init__(
self,
arc_color: str | None = None,
arc_opacity: float = 0.4,
) -> None:
"""
Initialize moon range layer.
Args:
arc_color: Color for the shaded arc (defaults to Moon color from theme)
arc_opacity: Opacity of the shaded arc (0.0-1.0)
"""
self.arc_color = arc_color
self.arc_opacity = arc_opacity
[docs]
def render(
self, renderer: ChartRenderer, dwg: svgwrite.Drawing, chart: Any
) -> None:
"""Render the Moon range arc for unknown time charts."""
# Only render for UnknownTimeChart
if not isinstance(chart, UnknownTimeChart):
return
moon_range = chart.moon_range
if moon_range is None:
return
# Get planet ring radius (where planets are drawn)
planet_radius = renderer.radii.get("planet_ring", renderer.size * 0.35)
# Get Moon color from theme
# Use planets.glyph_color for consistency with how the Moon glyph is rendered
style = renderer.style
planet_style = style.get("planets", {})
default_glyph_color = planet_style.get("glyph_color", "#8B8B8B")
if self.arc_color:
# Custom color override
fill_color = self.arc_color
elif renderer.planet_glyph_palette:
# If there's a planet glyph palette, try to get Moon-specific color
planet_palette = PlanetGlyphPalette(renderer.planet_glyph_palette)
fill_color = get_planet_glyph_color(
"Moon", planet_palette, default_glyph_color
)
else:
# Use the theme's planet glyph color (same as Moon glyph)
fill_color = default_glyph_color
# Determine arc radii - slightly inside and outside the planet ring
arc_width = renderer.size * 0.04 # 4% of chart size
inner_radius = planet_radius - arc_width / 2
outer_radius = planet_radius + arc_width / 2
# Use renderer.polar_to_cartesian for correct coordinate transformation
# This handles rotation, centering, and SVG coordinate system automatically
start_lon = moon_range.start_longitude
end_lon = moon_range.end_longitude
# Get the four corner points using the renderer's coordinate system
outer_start_x, outer_start_y = renderer.polar_to_cartesian(
start_lon, outer_radius
)
outer_end_x, outer_end_y = renderer.polar_to_cartesian(end_lon, outer_radius)
inner_start_x, inner_start_y = renderer.polar_to_cartesian(
start_lon, inner_radius
)
inner_end_x, inner_end_y = renderer.polar_to_cartesian(end_lon, inner_radius)
# Create the arc path
path_data = self._create_arc_path(
outer_start_x,
outer_start_y,
outer_end_x,
outer_end_y,
inner_start_x,
inner_start_y,
inner_end_x,
inner_end_y,
inner_radius,
outer_radius,
moon_range.arc_size,
)
# Draw the shaded arc
dwg.add(
dwg.path(
d=path_data,
fill=fill_color,
fill_opacity=self.arc_opacity,
stroke="none",
)
)
# Optionally: draw subtle border on the arc
dwg.add(
dwg.path(
d=path_data,
fill="none",
stroke=fill_color,
stroke_width=0.5,
stroke_opacity=self.arc_opacity * 2,
)
)
def _create_arc_path(
self,
outer_start_x: float,
outer_start_y: float,
outer_end_x: float,
outer_end_y: float,
inner_start_x: float,
inner_start_y: float,
inner_end_x: float,
inner_end_y: float,
inner_r: float,
outer_r: float,
arc_size_deg: float,
) -> str:
"""
Create SVG path data for an annular sector (donut slice).
Args:
outer_start_x/y: Outer arc start point
outer_end_x/y: Outer arc end point
inner_start_x/y: Inner arc start point (at start longitude)
inner_end_x/y: Inner arc end point (at end longitude)
inner_r, outer_r: Inner and outer radii for arc commands
arc_size_deg: Size of the arc in degrees
Returns:
SVG path data string
"""
# For a small arc (< 180°), large_arc_flag = 0
# Moon range is always < 180° (typically ~12-14°)
large_arc = 0 if arc_size_deg < 180 else 1
# Sweep flag: 0 = counter-clockwise, 1 = clockwise
# In the chart's visual system, zodiac goes counter-clockwise
# So Moon moving from start to end (increasing longitude) goes counter-clockwise
# SVG sweep=0 is counter-clockwise
sweep_outer = 0
sweep_inner = 1 # Opposite direction for inner arc to close the shape
# Build path:
# M = move to outer start
# A = arc to outer end
# L = line to inner end (at end longitude)
# A = arc back to inner start
# Z = close path
path = (
f"M {outer_start_x:.2f},{outer_start_y:.2f} "
f"A {outer_r:.2f},{outer_r:.2f} 0 {large_arc},{sweep_outer} {outer_end_x:.2f},{outer_end_y:.2f} "
f"L {inner_end_x:.2f},{inner_end_y:.2f} "
f"A {inner_r:.2f},{inner_r:.2f} 0 {large_arc},{sweep_inner} {inner_start_x:.2f},{inner_start_y:.2f} "
f"Z"
)
return path