"""
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()