Source code for stellium.presentation.sections.transit_periods

"""
Transit period sections for natal chart transit analysis.

Computes when transiting planets form aspects to natal positions,
with orb entry/exit windows and multi-pass handling for retrograde transits.

Two output modes:
- TransitListSection: plain-text rows, e.g.:
    Dec 2 - Mar 2 '26 — Jupiter △ natal Chiron
    Dec 4 — Mercury □ natal Jupiter
    Jan 8 - Feb 6 '26 — Uranus △ natal Neptune (3x: Jan 15, Jan 30, Feb 12)

- TransitGanttSection: SVG horizontal bar chart grouped by transiting planet.
  Rows are aspect events; bars show orb window; tick marks show exact dates.

Both sections take an explicit start/end date range and accept a natal
CalculatedChart via generate_data() following the ReportSection protocol.
"""

from __future__ import annotations

import datetime as dt
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from stellium.core.models import CalculatedChart

# ---------------------------------------------------------------------------
# Default configuration
# ---------------------------------------------------------------------------

# Default transiting planets for natal transit analysis
DEFAULT_TRANSIT_PLANETS = [
    "Sun",
    "Moon",
    "Mercury",
    "Venus",
    "Mars",
    "Jupiter",
    "Saturn",
    "Uranus",
    "Neptune",
    "Pluto",
    "True Node",
    "Chiron",
]

# Default aspects with transit orbs (tighter than natal chart orbs)
DEFAULT_TRANSIT_ASPECTS: dict[str, float] = {
    "Conjunction": 2.0,
    "Sextile": 1.5,
    "Square": 2.0,
    "Trine": 2.0,
    "Opposition": 2.0,
}

# Planet colors for Gantt chart
_PLANET_COLORS: dict[str, str] = {
    "Sun": "#F4C542",
    "Moon": "#A8C8E8",
    "Mercury": "#B5D5A0",
    "Venus": "#E8A0C0",
    "Mars": "#E07060",
    "Jupiter": "#C0A060",
    "Saturn": "#8090A0",
    "Uranus": "#70B8C0",
    "Neptune": "#7090D0",
    "Pluto": "#906880",
    "True Node": "#C8A060",
    "Chiron": "#90A870",
}

# Aspect colors for Gantt chart bars
_ASPECT_COLORS: dict[str, str] = {
    "Conjunction": "#E07060",
    "Sextile": "#70B870",
    "Square": "#F4A030",
    "Trine": "#60A0D0",
    "Opposition": "#C060B0",
}

# Fast planets excluded from Gantt by default (too many short transits)
_FAST_PLANETS = {"Sun", "Moon", "Mercury", "Venus", "Mars"}

# Maximum days between crossings to be considered the same retrograde transit
_MAX_WINDOW_DAYS = 200

# Minimum days between exact crossings — deduplicate near-station oscillations
# (slow planets near a station can trigger many crossings within a few days)
_MIN_CROSSING_SEPARATION_DAYS = 3


# ---------------------------------------------------------------------------
# TransitPeriod dataclass
# ---------------------------------------------------------------------------


[docs] @dataclass(frozen=True) class TransitPeriod: """ A single transit event: one transiting planet forming one aspect to one natal point. Includes the orb entry/exit window and all exact dates within the window. Multi-pass transits (retrograde causing 2–3 exact crossings) are represented as a single TransitPeriod with multiple exact_dates entries. Attributes: transit_planet: Name of the transiting planet (e.g. "Jupiter") natal_planet: Name of the natal point being aspected (e.g. "Sun") aspect_name: Name of the aspect (e.g. "Trine") aspect_angle: Aspect angle in degrees (e.g. 120.0) orb: Orb used for this calculation in degrees exact_dates: One or more exact-aspect datetimes (UTC); 2–3 = retrograde passes start: When transit entered orb, or None if before the search window end: When transit exits orb, or None if extends beyond search window """ transit_planet: str natal_planet: str aspect_name: str aspect_angle: float orb: float exact_dates: tuple[dt.datetime, ...] start: dt.datetime | None end: dt.datetime | None @property def is_multi_pass(self) -> bool: """True if the transit crosses the exact point more than once (retrograde).""" return len(self.exact_dates) > 1 @property def peak_date(self) -> dt.datetime: """The middle exact date — most representative for sorting.""" return self.exact_dates[len(self.exact_dates) // 2] @property def duration_days(self) -> float | None: """Duration in days, or None if window extends outside the search range.""" if self.start and self.end: return (self.end - self.start).total_seconds() / 86400 return None
# --------------------------------------------------------------------------- # Core calculation # ---------------------------------------------------------------------------
[docs] def calculate_transit_periods( natal_chart: CalculatedChart, start: dt.datetime, end: dt.datetime, transit_planets: list[str] | None = None, aspects: dict[str, float] | None = None, include_natal_points: list[str] | None = None, ) -> list[TransitPeriod]: """ Calculate transit-to-natal aspect periods for a date range. For each (transiting planet × natal point × aspect) combination, returns TransitPeriod objects with orb entry/exit dates and all exact dates within the window, including multiple passes from retrograde motion. Reuses stellium.engines.search functions for all ephemeris lookups — no raw Swiss Ephemeris calls here. Args: natal_chart: The natal CalculatedChart to transit to. start: Start of date range (UTC). end: End of date range (UTC). transit_planets: Planets to use as transits (default: all 12). aspects: Dict of {aspect_name: orb_degrees} (default: 5 major). include_natal_points: Limit natal points checked (default: all planets). Returns: List of TransitPeriod objects sorted by start date (or first exact date if start is outside the search window). """ from stellium.core.registry import ASPECT_REGISTRY from stellium.engines.search import ( find_all_longitude_crossings, find_longitude_crossing, ) if transit_planets is None: transit_planets = DEFAULT_TRANSIT_PLANETS if aspects is None: aspects = DEFAULT_TRANSIT_ASPECTS # Resolve aspect angles from the registry aspect_configs: list[tuple[str, float, float]] = [] # (name, angle, orb) for aspect_name, orb in aspects.items(): if aspect_name in ASPECT_REGISTRY: angle = ASPECT_REGISTRY[aspect_name].angle aspect_configs.append((aspect_name, angle, orb)) # Get natal positions natal_positions = natal_chart.get_planets() if include_natal_points: natal_positions = [p for p in natal_positions if p.name in include_natal_points] periods: list[TransitPeriod] = [] for transit_planet in transit_planets: for natal_obj in natal_positions: natal_lon = natal_obj.longitude for aspect_name, aspect_angle, orb in aspect_configs: # Compute target longitude(s). # - Conjunction: one target at natal longitude # - Opposition: one target at natal + 180° # - All others: two targets (aspect forms from either side of the natal point) if aspect_angle == 0.0: target_lons = [natal_lon] elif aspect_angle == 180.0: target_lons = [(natal_lon + 180.0) % 360.0] else: target_lons = [ (natal_lon + aspect_angle) % 360.0, (natal_lon - aspect_angle) % 360.0, ] for target_lon in target_lons: try: exact_crossings = find_all_longitude_crossings( transit_planet, target_lon, start, end ) except Exception: continue if not exact_crossings: continue # Deduplicate near-station oscillations: slow planets near a # station can trigger many crossings within a few days of each # other. Keep only one crossing per _MIN_CROSSING_SEPARATION_DAYS. deduped = [exact_crossings[0]] for crossing in exact_crossings[1:]: if ( crossing.julian_day - deduped[-1].julian_day >= _MIN_CROSSING_SEPARATION_DAYS ): deduped.append(crossing) exact_crossings = deduped # Group crossings that belong to the same retrograde event. # Any two crossings within _MAX_WINDOW_DAYS are part of # the same direct→retrograde→direct transit arc. groups: list[list] = [] for crossing in exact_crossings: if ( groups and (crossing.julian_day - groups[-1][-1].julian_day) < _MAX_WINDOW_DAYS ): groups[-1].append(crossing) else: groups.append([crossing]) for group in groups: first = group[0] last = group[-1] exact_dates = tuple(c.datetime_utc for c in group) # Find orb entry: search backward from the first exact crossing. # Try both (target - orb) and (target + orb) and take the # LATER result — that's the actual entry into the orb window. t_minus = (target_lon - orb) % 360.0 t_plus = (target_lon + orb) % 360.0 c1 = find_longitude_crossing( transit_planet, t_minus, first.julian_day, direction="backward", max_days=90, ) c2 = find_longitude_crossing( transit_planet, t_plus, first.julian_day, direction="backward", max_days=90, ) entry_candidates = [c for c in (c1, c2) if c is not None] orb_start = ( max( entry_candidates, key=lambda c: c.julian_day ).datetime_utc if entry_candidates else None ) # Find orb exit: search forward from the last exact crossing. # Take the EARLIER result — that's the actual exit from the orb window. c3 = find_longitude_crossing( transit_planet, t_minus, last.julian_day, direction="forward", max_days=90, ) c4 = find_longitude_crossing( transit_planet, t_plus, last.julian_day, direction="forward", max_days=90, ) exit_candidates = [c for c in (c3, c4) if c is not None] orb_end = ( min( exit_candidates, key=lambda c: c.julian_day ).datetime_utc if exit_candidates else None ) periods.append( TransitPeriod( transit_planet=transit_planet, natal_planet=natal_obj.name, aspect_name=aspect_name, aspect_angle=aspect_angle, orb=orb, exact_dates=exact_dates, start=orb_start, end=orb_end, ) ) # Sort by orb start date, falling back to first exact date periods.sort(key=lambda p: p.start or p.exact_dates[0]) return periods
# --------------------------------------------------------------------------- # Date formatting helpers # --------------------------------------------------------------------------- def _fmt_date(d: dt.datetime, ref_year: int | None = None) -> str: """Format a date concisely, omitting the year if it matches ref_year.""" day_month = f"{d.day} {d.strftime('%b')}" if ref_year is not None and d.year == ref_year: return day_month short_year = str(d.year)[2:] return f"{day_month} '{short_year}" def _fmt_period(p: TransitPeriod, ref_year: int | None = None) -> str: """ Format a TransitPeriod as a human-readable string. Examples: Dec 2 - Mar 2 '26 — Jupiter △ natal Chiron Dec 4 — Mercury □ natal Jupiter Dec 2 - Mar 2 '26 — Jupiter △ natal Chiron (3x: Dec 15, Jan 8, Feb 12) """ from stellium.core.registry import ASPECT_REGISTRY glyph = ( ASPECT_REGISTRY[p.aspect_name].glyph if p.aspect_name in ASPECT_REGISTRY else p.aspect_name ) label = f"{p.transit_planet} {glyph} natal {p.natal_planet}" if p.start and p.end: date_str = f"{_fmt_date(p.start, ref_year)}{_fmt_date(p.end, ref_year)}" elif p.exact_dates: date_str = _fmt_date(p.exact_dates[0], ref_year) else: date_str = "?" result = f"{date_str}{label}" if p.is_multi_pass: pass_strs = ", ".join(_fmt_date(d, ref_year) for d in p.exact_dates) result += f" ({len(p.exact_dates)}x: {pass_strs})" return result # --------------------------------------------------------------------------- # TransitListSection # ---------------------------------------------------------------------------
[docs] class TransitListSection: """ Natal transit aspect periods as a plain-text list. Shows when transiting planets form aspects to natal positions, with orb entry/exit dates, e.g.:: Dec 2 - Mar 2 '26 — Jupiter △ natal Chiron Dec 4 — Mercury □ natal Jupiter Jan 8 - Feb 6 '26 — Uranus △ natal Neptune (3x: Jan 15, Jan 30, Feb 12) Usage:: section = TransitListSection( start=datetime(2025, 12, 1), end=datetime(2026, 6, 1), ) report = ReportBuilder().from_chart(natal_chart).add_section(section) report.render() Note: The chart parameter in generate_data() is the natal chart. """ def __init__( self, start: dt.datetime, end: dt.datetime, transit_planets: list[str] | None = None, aspects: dict[str, float] | None = None, include_natal_points: list[str] | None = None, exclude_fast_planets: bool = False, ) -> None: """ Initialize transit list section. Args: start: Start of transit window (UTC). end: End of transit window (UTC). transit_planets: Planets to use as transits (default: all 12). aspects: Dict of {aspect_name: orb_degrees} (default: 5 major). include_natal_points: Limit natal points checked (default: all planets). exclude_fast_planets: If True, exclude Sun, Moon, Mercury, Venus, Mars. """ self.start = start self.end = end self.aspects = aspects or DEFAULT_TRANSIT_ASPECTS planets = list(transit_planets or DEFAULT_TRANSIT_PLANETS) if exclude_fast_planets: planets = [p for p in planets if p not in _FAST_PLANETS] self.transit_planets = planets self.include_natal_points = include_natal_points @property def section_name(self) -> str: return "Natal Transits"
[docs] def generate_data(self, chart: CalculatedChart) -> dict[str, Any]: """ Calculate and return transit periods as text rows. Args: chart: The natal CalculatedChart to transit to. Returns: Dict with type="text" and structured period data. """ periods = calculate_transit_periods( natal_chart=chart, start=self.start, end=self.end, transit_planets=self.transit_planets, aspects=self.aspects, include_natal_points=self.include_natal_points, ) ref_year = self.start.year lines = [_fmt_period(p, ref_year) for p in periods] return { "type": "text", "title": self.section_name, "subtitle": ( f"{self.start.strftime('%Y-%m-%d')} to {self.end.strftime('%Y-%m-%d')}" ), "date_range": { "start": self.start.strftime("%Y-%m-%d"), "end": self.end.strftime("%Y-%m-%d"), }, "total_transits": len(periods), "text": "\n".join(lines), # Structured data for custom renderers "periods": [ { "transit_planet": p.transit_planet, "natal_planet": p.natal_planet, "aspect_name": p.aspect_name, "aspect_angle": p.aspect_angle, "orb": p.orb, "start": p.start.isoformat() if p.start else None, "end": p.end.isoformat() if p.end else None, "exact_dates": [d.isoformat() for d in p.exact_dates], "is_multi_pass": p.is_multi_pass, "duration_days": p.duration_days, "label": _fmt_period(p, ref_year), } for p in periods ], }
# --------------------------------------------------------------------------- # TransitGanttSection # --------------------------------------------------------------------------- # SVG layout constants _LABEL_WIDTH = 200 _RIGHT_PAD = 20 _HEADER_HEIGHT = 30 _ROW_HEIGHT = 14 _ROW_PAD = 2 _LEGEND_HEIGHT = 35 # extra space at bottom for legend # Theme presets _THEMES: dict[str, dict[str, str]] = { "dark": { "bg": "#1a1a2e", "grid": "#333355", "text": "#cccccc", "month": "#8888aa", "today": "#ffffff", "tick": "#ffffff", "legend_text": "#999999", }, "light": { "bg": "#ffffff", "grid": "#ddddee", "text": "#333333", "month": "#666688", "today": "#333333", "tick": "#333333", "legend_text": "#666666", }, } # Legacy constants (default to dark theme for backward compat) _BG_COLOR = _THEMES["dark"]["bg"] _GRID_COLOR = _THEMES["dark"]["grid"] _TEXT_COLOR = _THEMES["dark"]["text"] _MONTH_COLOR = _THEMES["dark"]["month"]
[docs] class TransitGanttSection: """ Natal transit periods as an SVG Gantt timeline chart. Each row is a transit event; bars span the orb window; tick marks indicate exact dates. Rows are grouped by transiting planet. Defaults to excluding fast planets (Sun, Moon, Mercury, Venus, Mars) since their many short transits make the chart unreadable. Use TransitListSection for comprehensive fast-planet output. Usage:: section = TransitGanttSection( start=datetime(2025, 12, 1), end=datetime(2026, 6, 1), ) report = ReportBuilder().from_chart(natal_chart).add_section(section) report.render(format="pdf") """ def __init__( self, start: dt.datetime, end: dt.datetime, transit_planets: list[str] | None = None, aspects: dict[str, float] | None = None, include_natal_points: list[str] | None = None, width: int = 900, row_height: int = _ROW_HEIGHT, exclude_fast_planets: bool = True, theme: str = "dark", ) -> None: """ Initialize transit Gantt section. Args: start: Start of transit window (UTC). end: End of transit window (UTC). transit_planets: Planets to use as transits (default: all 12). aspects: Dict of {aspect_name: orb_degrees} (default: 5 major). include_natal_points: Limit natal points checked (default: all planets). width: Total SVG width in pixels (default: 900). row_height: Height of each row in pixels (default: 14). exclude_fast_planets: If True, exclude Sun, Moon, Mercury, Venus, Mars (default: True — they overwhelm the chart). theme: Color theme — "dark" or "light" (default: "dark"). """ self.start = start self.end = end self.aspects = aspects or DEFAULT_TRANSIT_ASPECTS planets = list(transit_planets or DEFAULT_TRANSIT_PLANETS) if exclude_fast_planets: planets = [p for p in planets if p not in _FAST_PLANETS] self.transit_planets = planets self.include_natal_points = include_natal_points self.width = width self.row_height = row_height self.theme = _THEMES.get(theme, _THEMES["dark"]) @property def section_name(self) -> str: return "Transit Timeline"
[docs] def generate_data(self, chart: CalculatedChart) -> dict[str, Any]: """ Calculate and return transit periods as an SVG Gantt chart. Args: chart: The natal CalculatedChart to transit to. Returns: Dict with type="svg" and SVG content string. """ periods = calculate_transit_periods( natal_chart=chart, start=self.start, end=self.end, transit_planets=self.transit_planets, aspects=self.aspects, include_natal_points=self.include_natal_points, ) svg_content = self._render_gantt(periods) height = self._calc_height(periods) return { "type": "svg", "title": self.section_name, "subtitle": ( f"{self.start.strftime('%Y-%m-%d')} to {self.end.strftime('%Y-%m-%d')}" ), "content": svg_content, "width": self.width, "height": height, "total_transits": len(periods), }
def _calc_height(self, periods: list[TransitPeriod]) -> int: """Calculate total SVG height based on number of rows.""" by_planet: dict[str, list] = defaultdict(list) for p in periods: by_planet[p.transit_planet].append(p) # Count: one header row per planet + one data row per period + legend n_rows = sum(1 + len(ps) for ps in by_planet.values()) return ( _HEADER_HEIGHT + n_rows * (self.row_height + _ROW_PAD) + _LEGEND_HEIGHT + 10 ) def _render_gantt(self, periods: list[TransitPeriod]) -> str: """Render periods as an SVG Gantt chart string.""" import svgwrite from stellium.core.registry import ASPECT_REGISTRY if not periods: dwg = svgwrite.Drawing(size=(400, 40)) dwg.add( dwg.text( "No transits found in this period.", insert=(10, 25), font_family="sans-serif", font_size=12, fill="#666666", ) ) return dwg.tostring() chart_width = self.width - _LABEL_WIDTH - _RIGHT_PAD total_days = max((self.end - self.start).total_seconds() / 86400, 1) def date_to_x(d: dt.datetime) -> float: days = (d - self.start).total_seconds() / 86400 return _LABEL_WIDTH + (days / total_days) * chart_width # Group periods by transiting planet, preserving transit_planets order by_planet: dict[str, list[TransitPeriod]] = defaultdict(list) for p in periods: by_planet[p.transit_planet].append(p) # Build ordered row list: (planet_name, period_or_None_for_header) rows: list[tuple[str, TransitPeriod | None]] = [] for planet in self.transit_planets: if planet not in by_planet: continue rows.append((planet, None)) # planet group header for p in by_planet[planet]: rows.append((planet, p)) height = self._calc_height(periods) th = self.theme dwg = svgwrite.Drawing(size=(self.width, height)) # Background dwg.add(dwg.rect(insert=(0, 0), size=(self.width, height), fill=th["bg"])) # Month grid lines and labels current = self.start.replace(day=1) while current <= self.end: x = date_to_x(current) if x >= _LABEL_WIDTH: dwg.add( dwg.line( start=(x, _HEADER_HEIGHT - 8), end=(x, height - _LEGEND_HEIGHT), stroke=th["grid"], stroke_width=0.5, ) ) month_label = ( current.strftime("%b %Y") if current.month == 1 else current.strftime("%b") ) dwg.add( dwg.text( month_label, insert=(x + 3, _HEADER_HEIGHT - 10), font_family="sans-serif", font_size=9, fill=th["month"], ) ) # Advance to next month if current.month == 12: current = current.replace(year=current.year + 1, month=1) else: current = current.replace(month=current.month + 1) # Today marker (dashed white line) # Use timezone-aware UTC if start/end are aware; naive UTC otherwise if self.start.tzinfo is not None: today = dt.datetime.now(dt.UTC) else: today = dt.datetime.now(dt.UTC).replace(tzinfo=None) if self.start <= today <= self.end: tx = date_to_x(today) dwg.add( dwg.line( start=(tx, _HEADER_HEIGHT - 8), end=(tx, height - _LEGEND_HEIGHT), stroke=th["today"], stroke_width=0.8, stroke_dasharray="3,3", opacity=0.4, ) ) # Rows rh = self.row_height rpad = _ROW_PAD for row_idx, (planet, period) in enumerate(rows): y_top = _HEADER_HEIGHT + row_idx * (rh + rpad) y_mid = y_top + rh / 2 planet_color = _PLANET_COLORS.get(planet, "#aaaaaa") if period is None: # Planet group header row dwg.add( dwg.text( planet, insert=(_LABEL_WIDTH - 5, y_mid + 4), font_family="sans-serif", font_size=9, fill=planet_color, font_weight="bold", text_anchor="end", ) ) dwg.add( dwg.line( start=(_LABEL_WIDTH, y_top), end=(self.width - _RIGHT_PAD, y_top), stroke=th["grid"], stroke_width=0.3, ) ) else: aspect_glyph = ( ASPECT_REGISTRY[period.aspect_name].glyph if period.aspect_name in ASPECT_REGISTRY else "" ) bar_color = _ASPECT_COLORS.get(period.aspect_name, planet_color) # Row label: "△ Sun" etc. dwg.add( dwg.text( f"{aspect_glyph} {period.natal_planet}", insert=(_LABEL_WIDTH - 5, y_mid + 3), font_family="sans-serif", font_size=8, fill=th["text"], text_anchor="end", ) ) # Orb window bar — clamp to chart area raw_bar_x = date_to_x(period.start) if period.start else _LABEL_WIDTH raw_bar_end = ( date_to_x(period.end) if period.end else _LABEL_WIDTH + chart_width ) bar_x = max(_LABEL_WIDTH, raw_bar_x) bar_end = min(_LABEL_WIDTH + chart_width, raw_bar_end) bar_w = max(2.0, bar_end - bar_x) extends_left = raw_bar_x < _LABEL_WIDTH extends_right = raw_bar_end > _LABEL_WIDTH + chart_width dwg.add( dwg.rect( insert=(bar_x, y_top + 1), size=(bar_w, rh - 2), fill=bar_color, opacity=0.6, rx=2, ry=2, ) ) # Left arrow indicator (bar extends before the chart window) if extends_left: arrow_x = _LABEL_WIDTH + 3 arrow_y = y_mid dwg.add( dwg.polygon( points=[ (arrow_x, arrow_y), (arrow_x + 4, arrow_y - 3), (arrow_x + 4, arrow_y + 3), ], fill=th["tick"], opacity=0.5, ) ) # Right arrow indicator (bar extends past the chart window) if extends_right: arrow_x = _LABEL_WIDTH + chart_width - 3 arrow_y = y_mid dwg.add( dwg.polygon( points=[ (arrow_x, arrow_y), (arrow_x - 4, arrow_y - 3), (arrow_x - 4, arrow_y + 3), ], fill=th["tick"], opacity=0.5, ) ) # Exact date tick marks (vertical lines on bar) for exact_dt in period.exact_dates: ex = date_to_x(exact_dt) if _LABEL_WIDTH <= ex <= (_LABEL_WIDTH + chart_width): dwg.add( dwg.line( start=(ex, y_top + 1), end=(ex, y_top + rh - 1), stroke=th["tick"], stroke_width=1.0, opacity=0.85, ) ) # Legend — aspect colors + tick mark explanation legend_y = height - _LEGEND_HEIGHT + 12 legend_x = _LABEL_WIDTH # Aspect color swatches for aspect_name, color in _ASPECT_COLORS.items(): if aspect_name in self.aspects: dwg.add( dwg.rect( insert=(legend_x, legend_y - 6), size=(12, 8), fill=color, opacity=0.8, rx=1, ry=1, ) ) glyph = ( ASPECT_REGISTRY[aspect_name].glyph if aspect_name in ASPECT_REGISTRY else "" ) dwg.add( dwg.text( f"{glyph} {aspect_name}", insert=(legend_x + 16, legend_y + 1), font_family="sans-serif", font_size=8, fill=th["legend_text"], ) ) legend_x += 90 # Tick mark legend legend_x += 10 dwg.add( dwg.line( start=(legend_x, legend_y - 5), end=(legend_x, legend_y + 3), stroke=th["tick"], stroke_width=1.0, opacity=0.85, ) ) dwg.add( dwg.text( "Exact date", insert=(legend_x + 5, legend_y + 1), font_family="sans-serif", font_size=8, fill=th["legend_text"], ) ) # Arrow legend legend_x += 75 dwg.add( dwg.polygon( points=[ (legend_x, legend_y - 1), (legend_x + 4, legend_y - 4), (legend_x + 4, legend_y + 2), ], fill=th["tick"], opacity=0.5, ) ) dwg.add( dwg.text( "Extends beyond window", insert=(legend_x + 8, legend_y + 1), font_family="sans-serif", font_size=8, fill=th["legend_text"], ) ) return dwg.tostring()