Source code for stellium.presentation.sections.zr_visualization

"""
Zodiacal Releasing visualization section.

Generates SVG timeline visualizations similar to Honeycomb Collective style:
- Page 1: Overview (natal angles chart + period length reference table)
- Page 2: Stacked L1/L2/L3 timelines with peak shapes
"""

from __future__ import annotations

import base64
import datetime as dt
import os
from dataclasses import dataclass, field
from functools import lru_cache
from typing import TYPE_CHECKING, Any

import svgwrite

from stellium.core.models import CalculatedChart, ZRPeriod, ZRTimeline
from stellium.core.registry import CELESTIAL_REGISTRY
from stellium.engines.releasing import PLANET_PERIODS

from ._utils import get_sign_glyph

# =============================================================================
# Font Embedding Utilities
# =============================================================================


@lru_cache(maxsize=1)
def _get_embedded_font_style() -> str:
    """
    Get CSS style with embedded Noto Sans Symbols 2 font (base64 encoded).

    This ensures zodiac and planet glyphs render correctly in PDFs
    without requiring the font to be installed on the viewing system.

    Returns:
        CSS style string with @font-face declaration
    """
    # Find the font file
    font_dir = os.path.join(
        os.path.dirname(__file__),  # sections/
        "..",  # presentation/
        "..",  # stellium/
        "..",  # src/
        "..",  # project root
        "assets",
        "fonts",
    )
    font_path = os.path.join(font_dir, "NotoSansSymbols2-Regular.ttf")

    # Also check NotoSansSymbols-Regular as fallback
    if not os.path.exists(font_path):
        font_path = os.path.join(font_dir, "NotoSansSymbols-Regular.ttf")

    if not os.path.exists(font_path):
        # Can't embed font, return empty style
        return ""

    # Read and encode font
    with open(font_path, "rb") as f:
        font_data = f.read()
    font_base64 = base64.b64encode(font_data).decode("ascii")

    return f"""
        @font-face {{
            font-family: 'Noto Sans Symbols2';
            src: url('data:font/truetype;base64,{font_base64}') format('truetype');
            font-weight: normal;
            font-style: normal;
        }}
    """


def _add_font_defs(dwg: svgwrite.Drawing) -> None:
    """
    Add embedded font definitions to an SVG drawing.

    Args:
        dwg: The svgwrite Drawing to add font defs to
    """
    font_style = _get_embedded_font_style()
    if font_style:
        # Add style element to defs
        style = dwg.style(font_style)
        dwg.defs.add(style)


if TYPE_CHECKING:
    from datetime import date


# =============================================================================
# Constants & Styling
# =============================================================================

# Honeycomb-inspired color palette
COLORS = {
    "background": "#ffffff",
    "current_period": "#d8c8e8",  # Light purple highlight for current period
    "default_period": "#f0e8d8",  # Default period fill (cream)
    "post_loosing": "#e8e0d0",  # Lighter shade for post-LB
    "loosing_bond_stroke": "#2d2330",  # Dark outline for LB (thick)
    "peak_stroke": "#4a3353",  # Purple stroke for angular peaks
    "text_dark": "#2d2330",
    "text_muted": "#6b4d6e",
    "grid_line": "#d0c8c0",
    "label_badge": "#4a3353",
    "label_badge_text": "#ffffff",
}

# Dimensions
SVG_WIDTH = 800
OVERVIEW_HEIGHT = 520  # Bar graph + period table (explanation moved to Typst)
TIMELINE_HEIGHT = 500

# Timeline level heights and spacing
LEVEL_HEIGHT = 140  # Height allocated per timeline level (increased for taller bars)
LEVEL_SPACING = 30  # Vertical spacing between levels
TIMELINE_MARGIN_TOP = 80
TIMELINE_MARGIN_BOTTOM = 40
TIMELINE_MARGIN_X = 60

# Bar heights based on position from lot (matching overview bar graph pattern)
# Heights are proportional - angular signs are tallest, adjacent signs step down
BAR_HEIGHTS = {
    1: 70,  # 1st from lot (primary angular)
    2: 52,  # Adjacent to 1
    3: 38,  # Adjacent to 4
    4: 50,  # 4th from lot (secondary angular)
    5: 38,  # Adjacent to 4
    6: 38,  # Adjacent to 7
    7: 50,  # 7th from lot (secondary angular)
    8: 38,  # Adjacent to 7
    9: 52,  # Adjacent to 10
    10: 70,  # 10th from lot (PEAK - primary angular)
    11: 52,  # Adjacent to 10
    12: 52,  # Adjacent to 1
}

# Sign order (zodiacal)
SIGNS = [
    "Aries",
    "Taurus",
    "Gemini",
    "Cancer",
    "Leo",
    "Virgo",
    "Libra",
    "Scorpio",
    "Sagittarius",
    "Capricorn",
    "Aquarius",
    "Pisces",
]

# Planet period order (for table)
PERIOD_RULERS = ["Venus", "Jupiter", "Mars", "Sun", "Mercury", "Moon", "Saturn"]


[docs] @dataclass class ZRVizConfig: """Configuration for ZR visualization.""" # Date range year: int | None = None start_date: date | None = None end_date: date | None = None # Display options levels: tuple[int, ...] = (1, 2, 3) highlight_date: dt.datetime | None = None show_loosing_bond: bool = True show_overview: bool = True show_timeline: bool = True # Styling width: int = SVG_WIDTH colors: dict = field(default_factory=lambda: COLORS.copy())
[docs] class ZRVisualizationSection: """ Zodiacal Releasing visualization section. Generates SVG timeline visualizations in Honeycomb Collective style: - Overview page: natal angles chart + period length reference - Timeline page: stacked L1/L2/L3 timelines with peak shapes Returns SVG content that can be embedded in PDF planners or reports. Example: section = ZRVisualizationSection( lot="Part of Fortune", year=2025, output="timeline" # or "overview" or "both" ) data = section.generate_data(chart) # data["content"] contains SVG string """ def __init__( self, lot: str = "Part of Fortune", year: int | None = None, start_date: date | None = None, end_date: date | None = None, levels: tuple[int, ...] = (1, 2, 3), highlight_date: dt.datetime | None = None, output: str = "both", # "overview", "timeline", or "both" ) -> None: """ Initialize ZR visualization section. Args: lot: Which lot to visualize (e.g., "Part of Fortune") year: Year to visualize (sets Jan 1 - Dec 31 range) start_date: Custom start date (alternative to year) end_date: Custom end date (alternative to year) levels: Which levels to show in timeline (default: 1, 2, 3) highlight_date: Date to highlight as "current" (default: now) output: What to generate - "overview", "timeline", or "both" """ self.lot = lot self.year = year self.start_date = start_date self.end_date = end_date self.levels = levels self.highlight_date = highlight_date or dt.datetime.now(dt.UTC) self.output = output @property def section_name(self) -> str: return f"Zodiacal Releasing Visualization ({self.lot})"
[docs] def generate_data(self, chart: CalculatedChart) -> dict[str, Any]: """ Generate ZR visualization data. Returns: Dict with type="svg" or type="compound" containing SVG content(s) """ # Check if ZR data exists if "zodiacal_releasing" not in chart.metadata: return { "type": "text", "content": ( "Zodiacal Releasing not calculated. Add ZodiacalReleasingAnalyzer:\n\n" " from stellium.engines.releasing import ZodiacalReleasingAnalyzer\n\n" " chart = (\n" " ChartBuilder.from_native(native)\n" " .add_analyzer(ZodiacalReleasingAnalyzer(['Part of Fortune']))\n" " .calculate()\n" " )" ), } zr_data = chart.metadata["zodiacal_releasing"] if self.lot not in zr_data: available = ", ".join(zr_data.keys()) return { "type": "text", "content": f"Lot '{self.lot}' not found. Available: {available}", } timeline: ZRTimeline = zr_data[self.lot] # Determine date range if self.year: start = dt.date(self.year, 1, 1) end = dt.date(self.year, 12, 31) elif self.start_date and self.end_date: start = self.start_date end = self.end_date else: # Default to current year now = dt.datetime.now() start = dt.date(now.year, 1, 1) end = dt.date(now.year, 12, 31) # Build config config = ZRVizConfig( year=self.year, start_date=start, end_date=end, levels=self.levels, highlight_date=self.highlight_date, show_overview=self.output in ("overview", "both"), show_timeline=self.output in ("timeline", "both"), ) # Generate SVG(s) and text sections results = [] if config.show_overview: overview_svg = self._render_overview(timeline, chart, config) results.append( ( "Zodiacal Releasing Overview", { "type": "svg", "content": overview_svg, "width": SVG_WIDTH, "height": OVERVIEW_HEIGHT, }, ) ) # Add explanation text (rendered by Typst for nice typography) results.append( ( "Understanding Zodiacal Releasing", { "type": "text", "text": self._get_explanation_text(self.lot), }, ) ) if config.show_timeline: timeline_svg = self._render_timeline(timeline, chart, config) results.append( ( f"Zodiacal Releasing from {self.lot}", { "type": "svg", "content": timeline_svg, "width": SVG_WIDTH, "height": TIMELINE_HEIGHT, }, ) ) if len(results) == 1: # Single output - return directly return results[0][1] else: # Multiple outputs - return as compound return { "type": "compound", "sections": results, }
# ========================================================================= # Overview Page Rendering # ========================================================================= def _render_overview( self, timeline: ZRTimeline, chart: CalculatedChart, config: ZRVizConfig ) -> str: """Render the overview page with natal angles and period length table.""" dwg = svgwrite.Drawing(size=(config.width, OVERVIEW_HEIGHT)) # Embed font for symbol rendering _add_font_defs(dwg) # Add background dwg.add( dwg.rect( (0, 0), (config.width, OVERVIEW_HEIGHT), fill=config.colors["background"], ) ) y_offset = 30 # Title dwg.add( dwg.text( "ZODIACAL RELEASING OVERVIEW", insert=(config.width / 2, y_offset), text_anchor="middle", font_family="Arial, sans-serif", font_size="18px", font_weight="bold", fill=config.colors["text_dark"], ) ) y_offset += 40 # Section 1: Natal Fortune Angles y_offset = self._render_natal_angles_section( dwg, timeline, chart, config, y_offset ) y_offset += 30 # Section 2: Period Length Reference self._render_period_length_table(dwg, config, y_offset) return dwg.tostring() def _render_natal_angles_section( self, dwg: svgwrite.Drawing, timeline: ZRTimeline, chart: CalculatedChart, config: ZRVizConfig, y_start: float, ) -> float: """Render the natal angles as a bar graph showing angular prominence.""" # Section header self._add_section_header(dwg, "NATAL FORTUNE ANGLES", 40, y_start, config) y_offset = y_start + 30 # Description text dwg.add( dwg.text( "Bar height shows angular strength. Brighter bars = peak periods.", insert=(40, y_offset), font_family="Arial, sans-serif", font_size="10px", fill=config.colors["text_muted"], ) ) y_offset += 20 # Bar graph dimensions chart_width = config.width - 80 bar_width = (chart_width / 12) * 0.7 # 70% of cell width for bar cell_width = chart_width / 12 chart_x = 40 max_bar_height = 100 baseline_y = y_offset + max_bar_height + 10 # Get natal planets by sign planets_by_sign: dict[str, list[str]] = {} for pos in chart.positions: if pos.sign not in planets_by_sign: planets_by_sign[pos.sign] = [] glyph = CELESTIAL_REGISTRY.get(pos.name, None) planets_by_sign[pos.sign].append(glyph.glyph if glyph else pos.name[:2]) lot_sign_idx = SIGNS.index(timeline.lot_sign) # Define bar heights and colors for each position from lot (1-12) # Position 1 = 1st from lot, Position 10 = 10th from lot (peak), etc. # Heights: 1,10 = highest; 4,7 = medium; adjacent signs step down bar_config = { 1: {"height": 1.0, "bright": True}, # 1st from lot (angular) 2: {"height": 0.75, "bright": False}, # Adjacent to 1 3: {"height": 0.50, "bright": False}, # Adjacent to 4 4: {"height": 0.65, "bright": True}, # 4th from lot (angular) 5: {"height": 0.50, "bright": False}, # Adjacent to 4 6: {"height": 0.50, "bright": False}, # Adjacent to 7 7: {"height": 0.65, "bright": True}, # 7th from lot (angular) 8: {"height": 0.50, "bright": False}, # Adjacent to 7 9: {"height": 0.75, "bright": False}, # Adjacent to 10 10: {"height": 1.0, "bright": True}, # 10th from lot (PEAK!) 11: {"height": 0.75, "bright": False}, # Adjacent to 10 12: {"height": 0.75, "bright": False}, # Adjacent to 1 } # Colors for bars bright_color = config.colors["peak_stroke"] # Purple for angular muted_color = config.colors["default_period"] # Cream for non-angular # Draw bars for i in range(12): position = i + 1 # 1-indexed position from lot sign_idx = (lot_sign_idx + i) % 12 sign = SIGNS[sign_idx] x = chart_x + i * cell_width + (cell_width - bar_width) / 2 cfg = bar_config[position] bar_height = max_bar_height * cfg["height"] bar_y = baseline_y - bar_height # Bar fill color fill_color = bright_color if cfg["bright"] else muted_color stroke_color = ( config.colors["text_dark"] if cfg["bright"] else config.colors["grid_line"] ) # Draw bar dwg.add( dwg.rect( (x, bar_y), (bar_width, bar_height), fill=fill_color, stroke=stroke_color, stroke_width=1, rx=3, ry=3, ) ) # Add sign glyph inside bar (near top) # Use white text on dark angular bars, dark text on light non-angular bars glyph = get_sign_glyph(sign) glyph_y = bar_y + 18 if bar_height > 30 else bar_y + bar_height / 2 + 5 text_color = "#ffffff" if cfg["bright"] else config.colors["text_muted"] dwg.add( dwg.text( glyph, insert=(x + bar_width / 2, glyph_y), text_anchor="middle", font_family="Noto Sans Symbols2, Arial", font_size="14px", fill=text_color, ) ) # Add planets inside bar if present if sign in planets_by_sign: planet_glyphs = planets_by_sign[sign] planet_start_y = glyph_y + 16 for j, planet_glyph in enumerate(planet_glyphs[:3]): # Max 3 if planet_start_y + j * 14 < baseline_y - 5: dwg.add( dwg.text( planet_glyph, insert=(x + bar_width / 2, planet_start_y + j * 14), text_anchor="middle", font_family="Noto Sans Symbols2, Arial", font_size="11px", fill=text_color, ) ) # Draw baseline dwg.add( dwg.line( (chart_x, baseline_y), (chart_x + chart_width, baseline_y), stroke=config.colors["grid_line"], stroke_width=1, ) ) # Add position numbers below baseline for i in range(12): x = chart_x + i * cell_width + cell_width / 2 dwg.add( dwg.text( str(i + 1), insert=(x, baseline_y + 15), text_anchor="middle", font_family="Arial, sans-serif", font_size="10px", fill=config.colors["text_muted"], ) ) # Add axis label lot_short = timeline.lot.replace("Part of ", "") dwg.add( dwg.text( f"Houses from {lot_short}", insert=(chart_x + chart_width / 2, baseline_y + 30), text_anchor="middle", font_family="Arial, sans-serif", font_size="11px", font_style="italic", fill=config.colors["text_muted"], ) ) return baseline_y + 45 def _draw_peak_indicator( self, dwg: svgwrite.Drawing, x: float, y: float, height: float, color: str ) -> None: """Draw a triangular peak indicator.""" points = [ (x - height / 2, y), (x, y - height), (x + height / 2, y), ] dwg.add(dwg.polygon(points, fill=color, opacity=0.3)) def _render_period_length_table( self, dwg: svgwrite.Drawing, config: ZRVizConfig, y_start: float ) -> float: """Render the period length reference table.""" self._add_section_header(dwg, "LENGTH OF GENERAL PERIODS", 40, y_start, config) y_offset = y_start + 30 # Description dwg.add( dwg.text( "Period length by planetary ruler. Level durations scale proportionally.", insert=(40, y_offset), font_family="Arial, sans-serif", font_size="10px", fill=config.colors["text_muted"], ) ) y_offset += 25 # Table headers headers = [ "Ruler", "Signs", "L1 (Years)", "L2 (Months)", "L3 (Days)", "L4 (Hours)", ] col_widths = [80, 140, 80, 80, 80, 80] x_start = 60 # Header row x = x_start for i, header in enumerate(headers): dwg.add( dwg.text( header, insert=(x, y_offset), font_family="Arial, sans-serif", font_size="10px", font_weight="bold", fill=config.colors["text_dark"], ) ) x += col_widths[i] # Draw header line (below headers, above data) dwg.add( dwg.line( (x_start, y_offset + 5), (x_start + sum(col_widths), y_offset + 5), stroke=config.colors["grid_line"], stroke_width=1, ) ) y_offset += 18 # Data rows ruler_signs = { "Venus": ["Taurus", "Libra"], "Jupiter": ["Sagittarius", "Pisces"], "Mars": ["Aries", "Scorpio"], "Sun": ["Leo"], "Mercury": ["Gemini", "Virgo"], "Moon": ["Cancer"], "Saturn": ["Capricorn", "Aquarius"], } for ruler in PERIOD_RULERS: years = PLANET_PERIODS[ruler] signs = ruler_signs[ruler] # Get ruler glyph ruler_info = CELESTIAL_REGISTRY.get(ruler) ruler_display = f"{ruler_info.glyph} {ruler}" if ruler_info else ruler # Format signs with glyphs sign_display = " ".join(get_sign_glyph(s) for s in signs) # Calculate level durations months = years # L2 months = L1 years days = years * 30.437 / 12 # Approximate hours = days * 24 / 30 # Approximate row_data = [ ruler_display, sign_display, f"{years}", f"{months}", f"{days:.1f}", f"{hours:.0f}", ] x = x_start for i, cell in enumerate(row_data): dwg.add( dwg.text( cell, insert=(x, y_offset), font_family="Noto Sans Symbols2, Arial, sans-serif", font_size="11px", fill=config.colors["text_dark"], ) ) x += col_widths[i] y_offset += 20 return y_offset def _get_explanation_text(self, lot_name: str) -> str: """Get explanatory text about Zodiacal Releasing for Typst rendering.""" # Lot-specific descriptions for ZR context lot_descriptions = { "Part of Fortune": ( "You are releasing from the Part of Fortune, the primary lot of embodiment " "and material experience. Fortune reveals the timing of your physical vitality, " "health fluctuations, material circumstances, and how life 'happens to you.' " "Peak periods often bring increased visibility, opportunities, or significant " "life events related to your body, resources, and worldly circumstances. This " "is the most commonly used lot for general life timing." ), "Part of Spirit": ( "You are releasing from the Part of Spirit, the lot of will, intellect, and " "purposeful action. Spirit reveals the timing of your vocational calling, " "career developments, and how you consciously shape your life. Peak periods " "often bring breakthroughs in work, recognition for your efforts, or pivotal " "decisions about your life direction. Spirit shows what you do, while Fortune " "shows what happens to you." ), "Part of Eros (Love)": ( "You are releasing from the Part of Eros, the lot of love, desire, and " "romantic connection. Eros reveals the timing of relationships, attractions, " "and matters of the heart. Peak periods often coincide with significant " "romantic encounters, deepening of bonds, or important relationship transitions." ), "Part of Necessity (Ananke)": ( "You are releasing from the Part of Necessity, the lot of constraints, fate, " "and unavoidable circumstances. Necessity reveals timing around struggles, " "enemies, and the things we must endure. Peak periods may bring challenges " "that ultimately strengthen character, or confrontations with limitations " "that reshape your path." ), "Part of Courage (Tolma)": ( "You are releasing from the Part of Courage, the lot of boldness, action, " "and assertive energy. Courage reveals timing for taking risks, confronting " "fears, and decisive action. Peak periods often demand bravery and can bring " "both triumphs and conflicts depending on how that martial energy is channeled." ), "Part of Victory (Nike)": ( "You are releasing from the Part of Victory, the lot of success, faith, and " "honors. Victory reveals timing for achievements, recognition, and fortunate " "alliances. Peak periods often bring rewards, public acknowledgment, or " "beneficial connections that elevate your standing." ), "Part of Nemesis": ( "You are releasing from the Part of Nemesis, the lot of hidden matters, karma, " "and that which comes due. Nemesis reveals timing around debts (literal or " "metaphorical), endings, and subconscious patterns surfacing. Peak periods " "may bring closure, reckoning, or the resolution of long-standing issues." ), } # Get lot-specific description or fall back to catalog if lot_name in lot_descriptions: lot_paragraph = lot_descriptions[lot_name] else: # Try to get from ARABIC_PARTS_CATALOG from stellium.components.arabic_parts import ARABIC_PARTS_CATALOG if lot_name in ARABIC_PARTS_CATALOG: catalog_desc = ARABIC_PARTS_CATALOG[lot_name].get("description", "") lot_paragraph = ( f"You are releasing from the {lot_name}. {catalog_desc} " "Peak periods in angular signs will intensify these themes, bringing " "increased activity and visibility in this life area." ) else: lot_paragraph = ( f"You are releasing from the {lot_name}. Peak periods in angular signs " "will bring increased activity and visibility in the life areas " "associated with this lot." ) return f"""{lot_paragraph} Zodiacal Releasing is a Hellenistic timing technique that divides life into major periods ruled by zodiac signs. Each sign's ruling planet colors the themes of that time. This technique was preserved by Vettius Valens in the 2nd century CE and has been revived by modern traditional astrologers. The bar graph above shows which signs are angular (most active) from your Lot. The tallest bars at positions 1 and 10 represent peak periods of heightened visibility and activity. Positions 4 and 7 are also angular but less intense. When your current period falls in one of these signs, expect increased momentum in that lot's life area. The table shows how long each planetary ruler's periods last. Saturn rules the longest periods (30 years at L1), while the Moon rules the shortest (25 years). Each level subdivides proportionally: L1 measures in years, L2 in months, L3 in days, and L4 in hours. This creates a fractal structure where the same planetary themes echo across different time scales. The timeline on the following page shows your actual periods with start and end dates. Trapezoidal shapes rise higher for angular signs. The current period is highlighted in warm cream. "Loosing of the Bond" periods (marked with darker outlines) indicate pivotal transitions when focus shifts. Use this to understand where you are in your life's unfolding story.""" def _add_section_header( self, dwg: svgwrite.Drawing, text: str, x: float, y: float, config: ZRVizConfig, ) -> None: """Add a styled section header badge.""" # Background badge text_width = len(text) * 7 + 20 dwg.add( dwg.rect( (x, y - 14), (text_width, 20), rx=3, ry=3, fill=config.colors["label_badge"], ) ) # Text dwg.add( dwg.text( text, insert=(x + 10, y), font_family="Arial, sans-serif", font_size="11px", font_weight="bold", fill=config.colors["label_badge_text"], ) ) # ========================================================================= # Timeline Page Rendering # ========================================================================= def _render_timeline( self, timeline: ZRTimeline, chart: CalculatedChart, config: ZRVizConfig ) -> str: """Render the timeline page with stacked L1/L2/L3 views.""" # Calculate height based on number of levels num_levels = len(config.levels) total_height = ( TIMELINE_MARGIN_TOP + num_levels * LEVEL_HEIGHT + (num_levels - 1) * LEVEL_SPACING + TIMELINE_MARGIN_BOTTOM ) dwg = svgwrite.Drawing(size=(config.width, total_height)) # Embed font for symbol rendering _add_font_defs(dwg) # Add background dwg.add( dwg.rect( (0, 0), (config.width, total_height), fill=config.colors["background"] ) ) # Title lot_short = self.lot.replace("Part of ", "") dwg.add( dwg.text( f"ZODIACAL RELEASING FROM {lot_short.upper()}", insert=(config.width / 2, 30), text_anchor="middle", font_family="Arial, sans-serif", font_size="18px", font_weight="bold", fill=config.colors["text_dark"], ) ) # Legend self._render_legend(dwg, config.width - 180, 20, config) # Render each level y_offset = TIMELINE_MARGIN_TOP for level in config.levels: self._render_level(dwg, timeline, chart, config, level, y_offset) y_offset += LEVEL_HEIGHT + LEVEL_SPACING return dwg.tostring() def _render_legend( self, dwg: svgwrite.Drawing, x: float, y: float, config: ZRVizConfig ) -> None: """Render the legend showing what colors/patterns mean.""" items = [ ("current", config.colors["current_period"], "Current period"), ("loosing", config.colors["loosing_bond_stroke"], "Loosing of the bond"), ("post_loosing", config.colors["post_loosing"], "Post-loosing phase"), ("default", config.colors["default_period"], "Regular period"), ] for i, (item_type, color, label) in enumerate(items): iy = y + i * 16 # Color swatch if item_type == "loosing": # Draw box with thick dark outline (like loosing of bond bars) dwg.add( dwg.rect( (x, iy), (12, 12), fill=config.colors["default_period"], stroke=color, stroke_width=2.5, rx=2, ry=2, ) ) else: # Regular filled box (current, post_loosing, default) dwg.add( dwg.rect( (x, iy), (12, 12), fill=color, stroke=config.colors["grid_line"], stroke_width=0.5, rx=2, ry=2, ) ) # Label dwg.add( dwg.text( label, insert=(x + 18, iy + 10), font_family="Arial, sans-serif", font_size="9px", fill=config.colors["text_muted"], ) ) def _render_level( self, dwg: svgwrite.Drawing, timeline: ZRTimeline, chart: CalculatedChart, config: ZRVizConfig, level: int, y_offset: float, ) -> None: """Render a single timeline level.""" # Level label level_names = {1: "LIFETIME VIEW", 2: "DECADE VIEW", 3: "YEAR VIEW"} level_name = level_names.get(level, f"LEVEL {level}") # Level badge self._add_section_header( dwg, f"LEVEL {level}", TIMELINE_MARGIN_X, y_offset, config ) dwg.add( dwg.text( level_name, insert=(TIMELINE_MARGIN_X + 70, y_offset), font_family="Arial, sans-serif", font_size="10px", fill=config.colors["text_muted"], ) ) y_offset += 20 # Get periods for this level periods = timeline.periods.get(level, []) if not periods: return # Determine visible date range based on level if level == 1: # Lifetime view: show all L1 periods visible_start = timeline.birth_date visible_end = timeline.birth_date + dt.timedelta(days=120 * 365.25) elif level == 2: # Decade view: ~10 years centered on highlight date center = config.highlight_date visible_start = center - dt.timedelta(days=5 * 365.25) visible_end = center + dt.timedelta(days=5 * 365.25) else: # Year view: use configured date range visible_start = dt.datetime.combine(config.start_date, dt.time.min) visible_end = dt.datetime.combine(config.end_date, dt.time.max) # Make datetime-aware if needed if visible_start.tzinfo is None: visible_start = visible_start.replace(tzinfo=dt.UTC) if visible_end.tzinfo is None: visible_end = visible_end.replace(tzinfo=dt.UTC) # Filter to visible periods visible_periods = [ p for p in periods if p.end > visible_start and p.start < visible_end ] if not visible_periods: return # Calculate x-axis scale timeline_width = config.width - 2 * TIMELINE_MARGIN_X total_span = (visible_end - visible_start).total_seconds() def date_to_x(d: dt.datetime) -> float: if d.tzinfo is None: d = d.replace(tzinfo=dt.UTC) elapsed = (d - visible_start).total_seconds() return TIMELINE_MARGIN_X + (elapsed / total_span) * timeline_width # Baseline y positions # Label baseline stays fixed, bar baseline is shifted up 5px label_baseline_y = y_offset + LEVEL_HEIGHT - 30 baseline_y = label_baseline_y - 5 # Bars drawn 5px higher # For Level 3, track post-loosing state within each L2 period post_loosing_state = False current_l2_start = None if level == 3: # Get L2 periods to track boundaries l2_periods = timeline.periods.get(2, []) # Draw each period for period in visible_periods: x1 = max(date_to_x(period.start), TIMELINE_MARGIN_X) x2 = min(date_to_x(period.end), config.width - TIMELINE_MARGIN_X) if x2 <= x1: continue # For Level 3, check if we're in post-loosing phase is_post_loosing = False if level == 3: # Find which L2 period contains this L3 period period_start = period.start if period_start.tzinfo is None: period_start = period_start.replace(tzinfo=dt.UTC) for l2_p in l2_periods: l2_start = l2_p.start l2_end = l2_p.end if l2_start.tzinfo is None: l2_start = l2_start.replace(tzinfo=dt.UTC) if l2_end.tzinfo is None: l2_end = l2_end.replace(tzinfo=dt.UTC) if l2_start <= period_start < l2_end: # Reset post-loosing state when entering new L2 period if current_l2_start != l2_start: current_l2_start = l2_start post_loosing_state = False break # If this period is loosing of bond, mark subsequent as post-loosing if period.is_loosing_bond: post_loosing_state = True elif post_loosing_state: is_post_loosing = True self._draw_period_shape( dwg, period, x1, x2, baseline_y, config, is_current=self._is_current_period(period, config), lot_sign=timeline.lot_sign, is_post_loosing=is_post_loosing, ) # For Level 3, draw vertical lines where Level 2 periods begin if level == 3: l2_periods = timeline.periods.get(2, []) for l2_period in l2_periods: # Check if L2 period start falls within visible range l2_start = l2_period.start if l2_start.tzinfo is None: l2_start = l2_start.replace(tzinfo=dt.UTC) if visible_start < l2_start < visible_end: x = date_to_x(l2_start) # Draw vertical line from top of chart area to baseline dwg.add( dwg.line( (x, y_offset - 15), (x, baseline_y), stroke=config.colors["peak_stroke"], stroke_width=1.5, stroke_dasharray="4,2", ) ) # Add L2 sign label at top l2_glyph = get_sign_glyph(l2_period.sign) dwg.add( dwg.text( f"L2: {l2_glyph}", insert=(x + 3, y_offset - 5), font_family="Noto Sans Symbols2, Arial", font_size="8px", fill=config.colors["peak_stroke"], ) ) # Draw date labels along bottom (using label baseline, not bar baseline) self._render_date_labels( dwg, visible_periods, date_to_x, label_baseline_y + 20, config ) def _draw_period_shape( self, dwg: svgwrite.Drawing, period: ZRPeriod, x1: float, x2: float, baseline_y: float, config: ZRVizConfig, is_current: bool = False, lot_sign: str = "Aries", is_post_loosing: bool = False, ) -> None: """ Draw period as a bar graph rectangle. Bar height based on position from lot: - 1st & 10th: tallest (primary angular) - 4th & 7th: medium-tall (secondary angular) - Adjacent to angular: slightly shorter than their neighbor """ # Calculate position from lot (1-12) based on sign lot_sign_idx = SIGNS.index(lot_sign) if lot_sign in SIGNS else 0 period_sign_idx = SIGNS.index(period.sign) if period.sign in SIGNS else 0 position = ((period_sign_idx - lot_sign_idx) % 12) + 1 # 1-indexed bar_height = BAR_HEIGHTS.get(position, 38) # Default to medium if unknown width = x2 - x1 bar_y = baseline_y - bar_height # Determine fill color if is_current: fill = config.colors["current_period"] # Light purple elif period.is_loosing_bond or is_post_loosing: fill = config.colors["post_loosing"] # Lighter shade for LB and post-LB else: fill = config.colors["default_period"] # Draw bar with rounded corners stroke = config.colors["grid_line"] stroke_width = 0.5 dwg.add( dwg.rect( (x1, bar_y), (width, bar_height), fill=fill, stroke=stroke, stroke_width=stroke_width, rx=2, ry=2, ) ) # Add loosing of bond indicator (thick dark border) if period.is_loosing_bond: dwg.add( dwg.rect( (x1, bar_y), (width, bar_height), fill="none", stroke=config.colors["loosing_bond_stroke"], stroke_width=2.5, rx=2, ry=2, ) ) # Add sign glyph inside bar (centered, scaled to fit) glyph = get_sign_glyph(period.sign) center_x = (x1 + x2) / 2 # Scale font size based on bar width if width >= 30: font_size = "12px" elif width >= 18: font_size = "10px" elif width >= 10: font_size = "8px" else: font_size = "6px" # Position glyph in upper portion of bar glyph_y = bar_y + min(18, bar_height * 0.5) dwg.add( dwg.text( glyph, insert=(center_x, glyph_y), text_anchor="middle", font_family="Noto Sans Symbols2, Arial", font_size=font_size, fill=config.colors["text_dark"], ) ) def _render_date_labels( self, dwg: svgwrite.Drawing, periods: list[ZRPeriod], date_to_x: callable, y: float, config: ZRVizConfig, ) -> None: """Render date labels at period boundaries.""" labeled_positions: set[int] = set() # Add extra spacing below bars for labels label_y = y + 8 for period in periods: x = date_to_x(period.start) x_rounded = int(x / 50) * 50 # Avoid overlapping labels if x_rounded not in labeled_positions: labeled_positions.add(x_rounded) # Format date based on period length if period.length_days > 365: date_str = period.start.strftime("%b %Y") elif period.length_days > 30: date_str = period.start.strftime("%b %Y") else: date_str = period.start.strftime("%d %b") dwg.add( dwg.text( date_str, insert=(x, label_y), text_anchor="start", font_family="Arial, sans-serif", font_size="8px", fill=config.colors["text_muted"], transform=f"rotate(-90, {x}, {label_y})", ) ) def _is_current_period(self, period: ZRPeriod, config: ZRVizConfig) -> bool: """Check if a period contains the highlight date.""" highlight = config.highlight_date if highlight.tzinfo is None: highlight = highlight.replace(tzinfo=dt.UTC) start = period.start end = period.end if start.tzinfo is None: start = start.replace(tzinfo=dt.UTC) if end.tzinfo is None: end = end.replace(tzinfo=dt.UTC) return start <= highlight < end