Source code for stellium.visualization.layout.engine

from dataclasses import dataclass
from typing import Protocol

from stellium.core.comparison import Comparison
from stellium.core.models import CalculatedChart
from stellium.core.multichart import MultiChart
from stellium.core.multiwheel import MultiWheel
from stellium.visualization.config import ChartVisualizationConfig, Dimensions
from stellium.visualization.layout.measurer import ContentMeasurer


[docs] @dataclass(frozen=True) class Position: """Represents x, y coordinates.""" x: float y: float
[docs] @dataclass(frozen=True) class BoundingBox: """Represents a positioned element with dimensions.""" position: Position dimensions: Dimensions @property def right(self) -> float: return self.position.x + self.dimensions.width @property def bottom(self) -> float: return self.position.y + self.dimensions.height
[docs] class ChartElement(Protocol): """Protocol for measurable chart elements."""
[docs] def measure(self) -> Dimensions: """Calculate dimensions without rendering.""" ...
[docs] @dataclass(frozen=True) class LayoutResult: """Complete layout specification - everything positioned!""" # Canvas canvas_dimensions: Dimensions # Chart wheel wheel_position: Position wheel_size: int wheel_radii: dict[str, float] # Info corners (only for enabled corners) corners: dict[str, BoundingBox] # e.g., {"top-left": BoundingBox(...)} # Tables (only for enabled tables) tables: dict[str, BoundingBox] # e.g., {"positions": BoundingBox(...)} # Useful metadata wheel_grew: bool # Did we grow the wheel due to large canvas? actual_margins: dict[str, float] # Actual margins used # Header (optional, with defaults) header_enabled: bool = False header_height: int = 0
[docs] @dataclass(frozen=True) class TableLayoutSpec: """ Specification for table layout (before final positioning). This represents the relative layout of tables to each other, which will be finalized once we know the wheel position. It's the blueprint for the tables. """ # Dimensions: total_width: float total_height: float # Relative positions of each table (before wheel positioning) table_positions: dict[str, Position] # e.g., {"positions": Position(0, 0)} table_dimensions: dict[str, Dimensions] # e.g., {"positions": Dimensions(200, 300)}
[docs] @staticmethod def empty() -> "TableLayoutSpec": """Create an empty layout spec when tables are disabled.""" return TableLayoutSpec( total_width=0, total_height=0, table_positions={}, table_dimensions={}, )
[docs] class LayoutEngine: """ The heart of the system - calculates all positions before rendering. This is a pure calculation engine - no rendering, no side effects. """ def __init__(self, config: ChartVisualizationConfig): self.config = config self.measurer = ContentMeasurer()
[docs] def calculate_layout( self, chart: CalculatedChart | Comparison | MultiWheel | MultiChart ) -> LayoutResult: """ Calculate complete layout for the chart. Steps: 1. Measure all enabled elements 2. Calculate table positions and sizes 3. Calculate required canvas size (scaled for multiwheel) 4. Calculate wheel size (possibly grown) 5. Center everything 6. Position info corners (with collision detection) 7. Return complete layout specification """ # Step 0: Determine effective base size (scale up for multiwheels) effective_base_size = self._get_effective_base_size(chart) # Step 1: Measure everything measurements = self._measure_all_elements(chart) # Step 2: Calculate table layout table_layout = self._calculate_table_layout(measurements) # Step 3: Calculate canvas size (using scaled base for multiwheel) canvas_dims = self._calculate_canvas_size( base_wheel_size=effective_base_size, table_layout=table_layout, measurements=measurements, ) # Step 4: Calculate wheel size (auto-grow if enabled) wheel_size = self._calculate_wheel_size(canvas_dims, effective_base_size) # Step 5: Position the wheel wheel_pos = self._position_wheel(canvas_dims, wheel_size, table_layout) # Step 6: Calculate wheel radii wheel_radii = self._calculate_wheel_radii(wheel_size, chart) # Step 7: Position info corners corners = self._position_info_corners(wheel_pos, wheel_size, measurements) # Step 8: Finalize table positions (relative to wheel) final_tables = self._finalize_table_positions( table_layout, wheel_pos, wheel_size ) return LayoutResult( canvas_dimensions=canvas_dims, header_enabled=self.config.header.enabled, header_height=self.config.header.height if self.config.header.enabled else 0, wheel_position=wheel_pos, wheel_size=wheel_size, wheel_radii=wheel_radii, corners=corners, tables=final_tables, wheel_grew=(wheel_size > effective_base_size), actual_margins=self._calculate_margins( canvas_dims, wheel_pos, wheel_size, final_tables ), )
def _measure_all_elements( self, chart: CalculatedChart | Comparison | MultiWheel | MultiChart ) -> dict[str, Dimensions]: """Measure all enabled elements.""" measurements = {} # Measure tables if enabled if self.config.tables.enabled: if self.config.tables.show_positions: measurements["position_table"] = self.measurer.measure_position_table( chart, self.config ) if self.config.tables.show_houses: measurements["house_table"] = self.measurer.measure_house_table( chart, self.config ) if self.config.tables.show_aspectarian: measurements["aspectarian"] = self.measurer.measure_aspectarian( chart, self.config ) # Measure corner elements # (Roughly fixed size anyway) for corner_name in [ "chart_info", "aspect_counts", "element_modality", "chart_shape", ]: if getattr(self.config.corners, corner_name, False): measurements[corner_name] = self.measurer.measure_corner_element( corner_name, chart, self.config ) return measurements def _calculate_table_layout( self, measurements: dict[str, Dimensions] ) -> TableLayoutSpec: """ Calculate where tables should go relative to each other. Returns a specification that can be finalized once we know wheel position. """ if not self.config.tables.enabled: return TableLayoutSpec.empty() placement = self.config.tables.placement padding = self.config.tables.padding gap = self.config.tables.gap_between_tables # Get enabled tables enabled_tables = [] if self.config.tables.show_positions: enabled_tables.append(("positions", measurements.get("position_table"))) if self.config.tables.show_houses: enabled_tables.append(("houses", measurements.get("house_table"))) if self.config.tables.show_aspectarian: enabled_tables.append(("aspectarian", measurements.get("aspectarian"))) # Calculate relative positions based on placement if placement == "right" or placement == "left": return self._layout_tables_vertically(enabled_tables, padding, gap) else: # placement = "below" return self._layout_tables_horizontally(enabled_tables, padding, gap) def _layout_tables_vertically( self, tables: list[tuple[str, Dimensions]], padding: int, gap: int ) -> TableLayoutSpec: """ Layout tables for vertical (right/left) placement. For single-wheel charts: - Position and House tables side-by-side in top row - Aspectarian below them (centered or full width) For comparison charts: - All tables stack vertically (old behavior) """ if not tables: return TableLayoutSpec.empty() table_positions = {} table_dimensions = {} # Check if we have the typical single-wheel set (positions, houses, aspectarian) table_names = [name for name, _ in tables] has_positions = "positions" in table_names has_houses = "houses" in table_names has_aspectarian = "aspectarian" in table_names # Single-wheel custom layout: positions + houses side-by-side, aspectarian below if has_positions and has_houses and len(tables) <= 3: positions_dims = next(dims for name, dims in tables if name == "positions") houses_dims = next(dims for name, dims in tables if name == "houses") # Top row: positions and houses side by side # Note: dimensions already include internal padding, so y=0 is correct table_positions["positions"] = Position(x=0, y=0) table_dimensions["positions"] = positions_dims table_positions["houses"] = Position(x=positions_dims.width + gap, y=0) table_dimensions["houses"] = houses_dims top_row_width = positions_dims.width + gap + houses_dims.width top_row_height = max(positions_dims.height, houses_dims.height) # Aspectarian below (if present) if has_aspectarian: aspectarian_dims = next( dims for name, dims in tables if name == "aspectarian" ) table_positions["aspectarian"] = Position(x=0, y=top_row_height + gap) table_dimensions["aspectarian"] = aspectarian_dims total_width = max(top_row_width, aspectarian_dims.width) total_height = top_row_height + gap + aspectarian_dims.height else: total_width = top_row_width total_height = top_row_height return TableLayoutSpec( total_width=total_width, total_height=total_height, table_positions=table_positions, table_dimensions=table_dimensions, ) # Default: stack vertically (old behavior for comparison charts) current_y = padding max_width = 0 for table_name, dims in tables: # Position this table table_positions[table_name] = Position(x=0, y=current_y) table_dimensions[table_name] = dims # Track max width max_width = max(max_width, dims.width) # Move down for next table current_y += dims.height + gap # Calculate total dimensions (remove last gap) total_width = max_width + (padding * 2) # padding on left and right total_height = current_y - gap + padding return TableLayoutSpec( total_width=total_width, total_height=total_height, table_positions=table_positions, table_dimensions=table_dimensions, ) def _layout_tables_horizontally( self, tables: list[tuple[str, Dimensions]], padding: int, gap: int ) -> TableLayoutSpec: """ Stack tables horizontally with proper spacing. For below placement, tables go left to right. """ if not tables: return TableLayoutSpec.empty() table_positions = {} table_dimensions = {} # Start from the left current_x = padding max_height = 0 for table_name, dims in tables: # Position this table table_positions[table_name] = Position(x=current_x, y=0) table_dimensions[table_name] = dims # Track maximum height max_height = max(max_height, dims.height) # Move right for the next table current_x += dims.width + gap # Calculate total dimensions (remove last gap) total_width = current_x - gap + padding total_height = max_height + (padding * 2) return TableLayoutSpec( total_width=total_width, total_height=total_height, table_positions=table_positions, table_dimensions=table_dimensions, ) def _get_effective_base_size( self, chart: CalculatedChart | Comparison | MultiWheel | MultiChart ) -> int: """ Get the effective base size, scaled for multiwheel charts. MultiWheel charts with more rings get larger canvases by default to keep the information readable. """ if isinstance(chart, MultiChart): scale = self.config.wheel.get_multiwheel_canvas_scale(chart.chart_count) return int(self.config.base_size * scale) elif isinstance(chart, MultiWheel): scale = self.config.wheel.get_multiwheel_canvas_scale(chart.chart_count) return int(self.config.base_size * scale) return self.config.base_size def _calculate_wheel_size( self, canvas_dims: Dimensions, effective_base_size: int ) -> int: """ Calculate wheel size, potentially growing it if canvas is large. Args: canvas_dims: The calculated canvas dimensions effective_base_size: The base size (possibly scaled for multiwheel) """ if not self.config.auto_grow_wheel: return effective_base_size # If canvas is significantly larger than base size, grow the wheel # This keeps the chart from looking tiny in a huge canvas max_dim = max(canvas_dims.width, canvas_dims.height) if max_dim > effective_base_size * 2: # Grow wheel by up to 30% growth_factor = min(1.3, max_dim / (effective_base_size * 2)) return int(effective_base_size * growth_factor) return effective_base_size def _calculate_wheel_radii( self, wheel_size: int, chart: CalculatedChart | Comparison | MultiWheel | MultiChart, ) -> dict[str, float]: """ Calculate all wheel radii based on wheel size and chart type. Config keys now match renderer keys directly - no mapping needed! For MultiWheel, uses the multiwheel_N_radii config based on chart count. """ # Determine which radii config to use is_multichart = isinstance(chart, MultiChart) is_multiwheel = isinstance(chart, MultiWheel) is_biwheel = ( isinstance(chart, Comparison) or self.config.wheel.chart_type == "biwheel" ) if is_multichart: # Use multiwheel-specific radii based on chart count multipliers = self.config.wheel.get_multiwheel_radii(chart.chart_count) elif is_multiwheel: # Use multiwheel-specific radii based on chart count multipliers = self.config.wheel.get_multiwheel_radii(chart.chart_count) elif is_biwheel: multipliers = self.config.wheel.biwheel_radii else: multipliers = self.config.wheel.single_radii # Direct multiplication - config keys = renderer keys! radii = {key: wheel_size * mult for key, mult in multipliers.items()} # Calculate derived radius (zodiac glyph is positioned between rings) radii["zodiac_glyph"] = wheel_size * ( (multipliers["zodiac_ring_outer"] + multipliers["zodiac_ring_inner"]) / 2 ) # Auto-select outer containment border based on table configuration # (Will be overridden in layer_factory based on actual show_info_stack value) if is_biwheel and not is_multiwheel: # Default to compact if tables with positions enabled (info stacks hidden) # Will be refined in layer_factory which has access to show_info_stack if self.config.tables.enabled and self.config.tables.show_positions: radii["outer_containment_border"] = radii.get( "outer_containment_border_compact", radii.get("outer_containment_border_full", 0), ) else: radii["outer_containment_border"] = radii.get( "outer_containment_border_full", radii.get("outer_containment_border_compact", 0), ) return radii def _position_info_corners( self, wheel_pos: Position, wheel_size: int, measurements: dict[str, Dimensions] ) -> dict[str, BoundingBox]: """ Position info corner elements outside the wheel's bounding box. Corners are positioned relative to the wheel's edges, but pushed outward to avoid overlapping the wheel itself. """ corners = {} # Gap between wheel edge and info corners corner_gap = 5 # Position each enabled corner corner_configs = [ ( "chart_info", self.config.corners.chart_info, self.config.corners.chart_info_position, ), ( "aspect_counts", self.config.corners.aspect_counts, self.config.corners.aspect_counts_position, ), ( "element_modality", self.config.corners.element_modality, self.config.corners.element_modality_position, ), ( "chart_shape", self.config.corners.chart_shape, self.config.corners.chart_shape_position, ), ] for name, enabled, position in corner_configs: if not enabled: continue dims = measurements.get(name, Dimensions(100, 80)) # Fallback dims # Calculate position based on corner - OUTSIDE wheel bounding box # TESTING with large values to verify positioning is working if position == "top-left": # Position above and to the left of wheel pos = Position( wheel_pos.x - dims.width - corner_gap, wheel_pos.y - dims.height - corner_gap, ) elif position == "top-right": # Aspect counter: TEST with 50px offset pos = Position( wheel_pos.x + wheel_size + corner_gap + 50, wheel_pos.y - dims.height - corner_gap - 50, ) elif position == "bottom-left": # Element modality table: TEST with 50px offset pos = Position( wheel_pos.x - dims.width - corner_gap - 50, wheel_pos.y + wheel_size + corner_gap + 50, ) else: # "bottom-right" # Position below and to the right of wheel pos = Position( wheel_pos.x + wheel_size + corner_gap, wheel_pos.y + wheel_size + corner_gap, ) corners[name] = BoundingBox(position=pos, dimensions=dims) # TODO: Add collision detection and auto-adjustment # if collisions are detected return corners def _calculate_canvas_size( self, base_wheel_size: int, table_layout: TableLayoutSpec, measurements: dict[str, Dimensions], ) -> Dimensions: """ Calculate required canvas size based on wheel + tables + corners + header. Logic: 1. Start with base wheel size 2. Add header height if enabled 3. Add table dimensions based on placement 4. Add padding for margins 5. Ensure minimum space for corner elements """ padding = self.config.min_margin * 2 # Start with wheel size + padding width = base_wheel_size + padding height = base_wheel_size + padding # Add header height if enabled if self.config.header.enabled: height += self.config.header.height # Add table dimensions based on placement if self.config.tables.enabled and table_layout: if self.config.tables.placement == "right": # Tables extend the width width += table_layout.total_width + self.config.tables.padding # Make sure height accommodates tallest element (wheel or tables) height = max(height, table_layout.total_height + padding) elif self.config.tables.placement == "left": # Tables extend the width width += table_layout.total_width + self.config.tables.padding # Make sure height accommodates tallest element (wheel or tables) height = max(height, table_layout.total_height + padding) elif self.config.tables.placement == "below": # Tables extend the height height += table_layout.total_height + self.config.tables.padding # Make sure width accommodates widest element (wheel or tables) width = max(width, table_layout.total_width + padding) # Ensure minimum space for corner elements # (They're positioned inside the wheel area, so no additional space needed) return Dimensions(width, height) def _position_wheel( self, canvas_dims: Dimensions, wheel_size: int, table_layout: TableLayoutSpec ) -> Position: """ Calculate wheel position within canvas. Centers the wheel, accounting for table placement and header. """ # Calculate header offset header_offset = self.config.header.height if self.config.header.enabled else 0 if not self.config.auto_center: # Simple top-left positioning with margin, accounting for header return Position( self.config.min_margin, self.config.min_margin + header_offset ) if not self.config.tables.enabled: # Simple centering when no tables x = (canvas_dims.width - wheel_size) / 2 # Center wheel in the space BELOW the header available_height = canvas_dims.height - header_offset y = header_offset + (available_height - wheel_size) / 2 return Position(x, y) # Adjust for table placement placement = self.config.tables.placement if placement == "right": # Wheel goes on the left, centered in available space available_width = ( canvas_dims.width - table_layout.total_width - self.config.tables.padding ) x = (available_width - wheel_size) / 2 # Center wheel in the space BELOW the header available_height = canvas_dims.height - header_offset y = header_offset + (available_height - wheel_size) / 2 elif placement == "left": # Wheel goes on the right, shifted by table width table_space = table_layout.total_width + self.config.tables.padding available_width = canvas_dims.width - table_space x = table_space + ((available_width - wheel_size) / 2) # Center wheel in the space BELOW the header available_height = canvas_dims.height - header_offset y = header_offset + (available_height - wheel_size) / 2 elif placement == "below": # Wheel goes on top (but below header), centered horizontally x = (canvas_dims.width - wheel_size) / 2 available_height = ( canvas_dims.height - header_offset - table_layout.total_height - self.config.tables.padding ) y = header_offset + (available_height - wheel_size) / 2 else: # Fallback to simple centering x = (canvas_dims.width - wheel_size) / 2 available_height = canvas_dims.height - header_offset y = header_offset + (available_height - wheel_size) / 2 return Position(x, y) def _finalize_table_positions( self, table_layout: TableLayoutSpec, wheel_pos: Position, wheel_size: int ) -> dict[str, BoundingBox]: """ Convert relative table positions to absolute canvas positions. Args: table_layout: The relative layout specification wheel_pos: Position of the wheel on canvas wheel_size: Size of the wheel Returns: Dictionary mapping table names to their final bounding boxes """ if not self.config.tables.enabled or not table_layout.table_positions: return {} final_tables = {} placement = self.config.tables.placement for table_name, relative_pos in table_layout.table_positions.items(): dims = table_layout.table_dimensions[table_name] # Calculate absolute position based on placement if placement == "right": # Tables start after the wheel + padding absolute_x = ( wheel_pos.x + wheel_size + self.config.tables.padding + relative_pos.x ) absolute_y = relative_pos.y elif placement == "left": # Tables are positioned at their relative position (already includes padding) absolute_x = relative_pos.x absolute_y = relative_pos.y elif placement == "below": # Tables start below the wheel + padding absolute_x = wheel_pos.x + relative_pos.x absolute_y = ( wheel_pos.y + wheel_size + self.config.tables.padding + relative_pos.y ) else: # Fallback absolute_x = relative_pos.x absolute_y = relative_pos.y final_tables[table_name] = BoundingBox( position=Position(absolute_x, absolute_y), dimensions=dims, ) return final_tables def _calculate_margins( self, canvas_dims: Dimensions, wheel_pos: Position, wheel_size: int, tables: dict[str, BoundingBox], ) -> dict[str, float]: """ Calculate actual margins between elements. Useful for debugging and validation. """ margins = { "wheel_left": wheel_pos.x, "wheel_top": wheel_pos.y, "wheel_right": canvas_dims.width - (wheel_pos.x + wheel_size), "wheel_bottom": canvas_dims.height - (wheel_pos.y + wheel_size), } # Calculate margins to tables if present if tables: if self.config.tables.placement == "right": first_table = next(iter(tables.values())) margins["table_gap"] = first_table.position.x - ( wheel_pos.x + wheel_size ) elif self.config.tables.placement == "left": first_table = next(iter(tables.values())) margins["table_gap"] = wheel_pos.x - ( first_table.position.x + first_table.dimensions.width ) elif self.config.tables.placement == "below": first_table = next(iter(tables.values())) margins["table_gap"] = first_table.position.y - ( wheel_pos.y + wheel_size ) return margins