|
@@ -0,0 +1,911 @@
|
|
|
|
|
+"""
|
|
|
|
|
+SVG chart wheel renderer for astro-mcp.
|
|
|
|
|
+
|
|
|
|
|
+Renders astrological chart wheels using the Astronomicon font.
|
|
|
|
|
+Supports natal, transit (bi-wheel), synastry (dual wheel), composite, and Davison charts.
|
|
|
|
|
+
|
|
|
|
|
+Reference: docs/astrological_chart_rendering_guide.md
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+from __future__ import annotations
|
|
|
|
|
+
|
|
|
|
|
+import math
|
|
|
|
|
+import logging
|
|
|
|
|
+from typing import Any
|
|
|
|
|
+
|
|
|
|
|
+import svgwrite
|
|
|
|
|
+
|
|
|
|
|
+from . import astrology
|
|
|
|
|
+from . import __version__
|
|
|
|
|
+from .chart_font import glyph, RETROGRADE
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger("astro-mcp.chart_renderer")
|
|
|
|
|
+
|
|
|
|
|
+# ── Font path (served by FastAPI static mount) ────────────────────────
|
|
|
|
|
+FONT_PATH = "/static/fonts/Astronomicon.ttf"
|
|
|
|
|
+
|
|
|
|
|
+# ── Color Themes ──────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+THEMES: dict[str, dict[str, Any]] = {
|
|
|
|
|
+ "color": {
|
|
|
|
|
+ "background": "#ffffff",
|
|
|
|
|
+ "ring_fill": "#f8f4e8",
|
|
|
|
|
+ "ring_stroke": "#8b7355",
|
|
|
|
|
+ "house_line": "#c9b896",
|
|
|
|
|
+ "house_fill_alt": "#f0ebe0",
|
|
|
|
|
+ "sign_text": "#4a3728",
|
|
|
|
|
+ "degree_text": "#6b5a4a",
|
|
|
|
|
+ "planet_text": "#1a1a2e",
|
|
|
|
|
+ "angle_text": "#8b0000",
|
|
|
|
|
+ "title_text": "#2c1810",
|
|
|
|
|
+ "data_text": "#4a3728",
|
|
|
|
|
+ "footer_text": "#999999",
|
|
|
|
|
+ "aspect_conjunction": "#e74c3c",
|
|
|
|
|
+ "aspect_opposition": "#e74c3c",
|
|
|
|
|
+ "aspect_square": "#c0392b",
|
|
|
|
|
+ "aspect_trine": "#2ecc71",
|
|
|
|
|
+ "aspect_sextile": "#3498db",
|
|
|
|
|
+ "aspect_quincunx": "#e67e22",
|
|
|
|
|
+ "aspect_minor": "#95a5a6",
|
|
|
|
|
+ "table_header": "#2c3e50",
|
|
|
|
|
+ "table_text": "#2c3e50",
|
|
|
|
|
+ "table_line": "#bdc3c7",
|
|
|
|
|
+ "table_row_alt": "#ecf0f1",
|
|
|
|
|
+ "zodiac_fire": "#e74c3c",
|
|
|
|
|
+ "zodiac_earth": "#27ae60",
|
|
|
|
|
+ "zodiac_air": "#f39c12",
|
|
|
|
|
+ "zodiac_water": "#3498db",
|
|
|
|
|
+ "axis_line": "#8b7355",
|
|
|
|
|
+ "tick_major": "#8b7355",
|
|
|
|
|
+ "tick_minor": "#c9b896",
|
|
|
|
|
+ },
|
|
|
|
|
+ "bw": {
|
|
|
|
|
+ "background": "#ffffff",
|
|
|
|
|
+ "ring_fill": "#ffffff",
|
|
|
|
|
+ "ring_stroke": "#000000",
|
|
|
|
|
+ "house_line": "#666666",
|
|
|
|
|
+ "house_fill_alt": "#f5f5f5",
|
|
|
|
|
+ "sign_text": "#000000",
|
|
|
|
|
+ "degree_text": "#333333",
|
|
|
|
|
+ "planet_text": "#000000",
|
|
|
|
|
+ "angle_text": "#000000",
|
|
|
|
|
+ "title_text": "#000000",
|
|
|
|
|
+ "data_text": "#333333",
|
|
|
|
|
+ "footer_text": "#999999",
|
|
|
|
|
+ "aspect_conjunction": "#000000",
|
|
|
|
|
+ "aspect_opposition": "#000000",
|
|
|
|
|
+ "aspect_square": "#000000",
|
|
|
|
|
+ "aspect_trine": "#000000",
|
|
|
|
|
+ "aspect_sextile": "#000000",
|
|
|
|
|
+ "aspect_quincunx": "#000000",
|
|
|
|
|
+ "aspect_minor": "#666666",
|
|
|
|
|
+ "table_header": "#000000",
|
|
|
|
|
+ "table_text": "#000000",
|
|
|
|
|
+ "table_line": "#999999",
|
|
|
|
|
+ "table_row_alt": "#f0f0f0",
|
|
|
|
|
+ "zodiac_fire": "#000000",
|
|
|
|
|
+ "zodiac_earth": "#000000",
|
|
|
|
|
+ "zodiac_air": "#000000",
|
|
|
|
|
+ "zodiac_water": "#000000",
|
|
|
|
|
+ "axis_line": "#000000",
|
|
|
|
|
+ "tick_major": "#000000",
|
|
|
|
|
+ "tick_minor": "#999999",
|
|
|
|
|
+ },
|
|
|
|
|
+ "dark": {
|
|
|
|
|
+ "background": "#0f1117",
|
|
|
|
|
+ "ring_fill": "#161b22",
|
|
|
|
|
+ "ring_stroke": "#30363d",
|
|
|
|
|
+ "house_line": "#21262d",
|
|
|
|
|
+ "house_fill_alt": "#1c2128",
|
|
|
|
|
+ "sign_text": "#8b949e",
|
|
|
|
|
+ "degree_text": "#6e7681",
|
|
|
|
|
+ "planet_text": "#c9d1d9",
|
|
|
|
|
+ "angle_text": "#ff7b72",
|
|
|
|
|
+ "title_text": "#58a6ff",
|
|
|
|
|
+ "data_text": "#8b949e",
|
|
|
|
|
+ "footer_text": "#484f58",
|
|
|
|
|
+ "aspect_conjunction": "#ff7b72",
|
|
|
|
|
+ "aspect_opposition": "#ff7b72",
|
|
|
|
|
+ "aspect_square": "#ff9a94",
|
|
|
|
|
+ "aspect_trine": "#3fb950",
|
|
|
|
|
+ "aspect_sextile": "#58a6ff",
|
|
|
|
|
+ "aspect_quincunx": "#d29922",
|
|
|
|
|
+ "aspect_minor": "#484f58",
|
|
|
|
|
+ "table_header": "#58a6ff",
|
|
|
|
|
+ "table_text": "#c9d1d9",
|
|
|
|
|
+ "table_line": "#30363d",
|
|
|
|
|
+ "table_row_alt": "#161b22",
|
|
|
|
|
+ "zodiac_fire": "#ff7b72",
|
|
|
|
|
+ "zodiac_earth": "#3fb950",
|
|
|
|
|
+ "zodiac_air": "#d29922",
|
|
|
|
|
+ "zodiac_water": "#58a6ff",
|
|
|
|
|
+ "axis_line": "#30363d",
|
|
|
|
|
+ "tick_major": "#8b949e",
|
|
|
|
|
+ "tick_minor": "#30363d",
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# B&W aspect line styles: (stroke_dasharray, stroke_width)
|
|
|
|
|
+BW_ASPECT_STYLES: dict[str, tuple[str, float]] = {
|
|
|
|
|
+ "conjunction": ("none", 2.0),
|
|
|
|
|
+ "opposition": ("none", 2.0),
|
|
|
|
|
+ "square": ("8,4", 2.0),
|
|
|
|
|
+ "trine": ("4,4", 1.5),
|
|
|
|
|
+ "sextile": ("3,3", 1.0),
|
|
|
|
|
+ "quincunx": ("6,3,2,3", 1.0),
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Color aspect line styles: (color_key, stroke_width)
|
|
|
|
|
+COLOR_ASPECT_STYLES: dict[str, tuple[str, float]] = {
|
|
|
|
|
+ "conjunction": ("aspect_conjunction", 2.0),
|
|
|
|
|
+ "opposition": ("aspect_opposition", 2.0),
|
|
|
|
|
+ "square": ("aspect_square", 2.0),
|
|
|
|
|
+ "trine": ("aspect_trine", 1.5),
|
|
|
|
|
+ "sextile": ("aspect_sextile", 1.5),
|
|
|
|
|
+ "quincunx": ("aspect_quincunx", 1.0),
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+# Angular houses (cusps 1, 4, 7, 10) get bolder lines
|
|
|
|
|
+ANGULAR_HOUSES = {1, 4, 7, 10}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Geometry helpers ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _svg_angle(lon: float) -> float:
|
|
|
|
|
+ """Convert ecliptic longitude to SVG angle (degrees).
|
|
|
|
|
+
|
|
|
|
|
+ 0° Aries at SVG top (12 o'clock), increasing counter-clockwise.
|
|
|
|
|
+ SVG: 0° = top, 90° = right, 180° = bottom, 270° = left.
|
|
|
|
|
+ """
|
|
|
|
|
+ return (90.0 - lon) % 360.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _rotate_for_asc(lon: float, asc_lon: float) -> float:
|
|
|
|
|
+ """Rotate a longitude so the ASC is at the 9 o'clock position (left).
|
|
|
|
|
+
|
|
|
|
|
+ The guide says: ASC is always at 9 o'clock (left, 180° in standard SVG).
|
|
|
|
|
+ We need to shift all positions so the ASC lands there.
|
|
|
|
|
+ """
|
|
|
|
|
+ return (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _polar_to_cartesian(cx: float, cy: float, r: float, angle_deg: float) -> tuple[float, float]:
|
|
|
|
|
+ """Polar to cartesian. Angle in degrees, 0 = top, CCW."""
|
|
|
|
|
+ rad = math.radians(angle_deg)
|
|
|
|
|
+ return cx + r * math.sin(rad), cy - r * math.cos(rad)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _arc_path(cx: float, cy: float, r: float, start_deg: float, end_deg: float) -> str:
|
|
|
|
|
+ """SVG arc path from start_deg to end_deg (CCW if end > start)."""
|
|
|
|
|
+ start = _polar_to_cartesian(cx, cy, r, start_deg)
|
|
|
|
|
+ end = _polar_to_cartesian(cx, cy, r, end_deg)
|
|
|
|
|
+ sweep = 1 # CCW
|
|
|
|
|
+ return (f"M {start[0]:.1f},{start[1]:.1f} "
|
|
|
|
|
+ f"A {r:.1f},{r:.1f} 0 0,{sweep} {end[0]:.1f},{end[1]:.1f}")
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Glyph helpers ─────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _zodiac_glyph(sign_name: str) -> str:
|
|
|
|
|
+ """Astronomicon character for a zodiac sign."""
|
|
|
|
|
+ name_map = {
|
|
|
|
|
+ "aries": "aries", "taurus": "taurus", "gemini": "gemini",
|
|
|
|
|
+ "cancer": "cancer", "leo": "leo", "virgo": "virgo",
|
|
|
|
|
+ "libra": "libra", "scorpio": "scorpio", "scorpius": "scorpio",
|
|
|
|
|
+ "sagittarius": "sagittarius", "capricorn": "capricorn",
|
|
|
|
|
+ "capricornus": "capricorn", "aquarius": "aquarius", "pisces": "pisces",
|
|
|
|
|
+ }
|
|
|
|
|
+ try:
|
|
|
|
|
+ return glyph(name_map.get(sign_name.lower(), sign_name.lower()))
|
|
|
|
|
+ except KeyError:
|
|
|
|
|
+ return "?"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _planet_glyph(body_name: str) -> str:
|
|
|
|
|
+ """Astronomicon character for a planet/point."""
|
|
|
|
|
+ try:
|
|
|
|
|
+ return glyph(body_name.lower().strip())
|
|
|
|
|
+ except KeyError:
|
|
|
|
|
+ return "?"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _format_degree(deg_float: float) -> str:
|
|
|
|
|
+ """Format 14.3698 as 14°22'"""
|
|
|
|
|
+ d = int(deg_float)
|
|
|
|
|
+ m = int(round((deg_float - d) * 60))
|
|
|
|
|
+ if m >= 60:
|
|
|
|
|
+ d += 1
|
|
|
|
|
+ m = 0
|
|
|
|
|
+ return f"{d}°{m:02d}'"
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Main natal wheel renderer ─────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def render_natal_wheel(
|
|
|
|
|
+ chart_data: dict[str, Any],
|
|
|
|
|
+ style: str = "modern",
|
|
|
|
|
+ color_mode: str = "color",
|
|
|
|
|
+ size: int = 600,
|
|
|
|
|
+ table_position: str = "none", # "none" | "below" | "right"
|
|
|
|
|
+ include_planets: bool = False,
|
|
|
|
|
+ include_houses: bool = False,
|
|
|
|
|
+ title: str | None = None,
|
|
|
|
|
+ subtitle: str | None = None,
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ """Render a natal chart wheel as SVG string.
|
|
|
|
|
+
|
|
|
|
|
+ Args:
|
|
|
|
|
+ chart_data: Output from calculate_natal_chart.
|
|
|
|
|
+ style: "modern" | "minimal"
|
|
|
|
|
+ color_mode: "color" | "bw" | "dark"
|
|
|
|
|
+ size: Wheel diameter in pixels. Total canvas may be larger if tables included.
|
|
|
|
|
+ table_position: Where to place data tables: "none", "below" (portrait), or "right" (landscape).
|
|
|
|
|
+ include_planets: Include planet data table.
|
|
|
|
|
+ include_houses: Include house cusp table.
|
|
|
|
|
+ title: Override auto-generated title line.
|
|
|
|
|
+ subtitle: Override auto-generated subtitle line.
|
|
|
|
|
+
|
|
|
|
|
+ Returns:
|
|
|
|
|
+ SVG string.
|
|
|
|
|
+ """
|
|
|
|
|
+ theme = THEMES.get(color_mode, THEMES["color"])
|
|
|
|
|
+ planets = chart_data.get("planets", [])
|
|
|
|
|
+ houses = chart_data.get("houses", [])
|
|
|
|
|
+ aspects = chart_data.get("aspects", [])
|
|
|
|
|
+ angles = chart_data.get("angles", {})
|
|
|
|
|
+ chart_type = chart_data.get("chart_type", "natal")
|
|
|
|
|
+ inp = chart_data.get("input", {})
|
|
|
|
|
+
|
|
|
|
|
+ # ── Determine canvas size ────────────────────────────────────────
|
|
|
|
|
+ has_tables = table_position in ("below", "right") and (include_planets or include_houses)
|
|
|
|
|
+ margin_top = 50 # space for title block
|
|
|
|
|
+ margin_bottom = 30 # space for footer
|
|
|
|
|
+ margin_sides = 20
|
|
|
|
|
+
|
|
|
|
|
+ if table_position == "right" and has_tables:
|
|
|
|
|
+ table_w = 220
|
|
|
|
|
+ canvas_w = size + table_w + margin_sides * 3
|
|
|
|
|
+ canvas_h = size + margin_top + margin_bottom
|
|
|
|
|
+ elif table_position == "below" and has_tables:
|
|
|
|
|
+ table_h = _estimate_table_height(chart_data, include_planets, include_houses)
|
|
|
|
|
+ canvas_w = size + margin_sides * 2
|
|
|
|
|
+ canvas_h = size + margin_top + margin_bottom + table_h + 10
|
|
|
|
|
+ else:
|
|
|
|
|
+ canvas_w = size + margin_sides * 2
|
|
|
|
|
+ canvas_h = size + margin_top + margin_bottom
|
|
|
|
|
+
|
|
|
|
|
+ # Wheel center (offset for title block)
|
|
|
|
|
+ wheel_cx = canvas_w / 2
|
|
|
|
|
+ wheel_cy = margin_top + (canvas_h - margin_top - margin_bottom) / 2
|
|
|
|
|
+ if table_position == "right" and has_tables:
|
|
|
|
|
+ wheel_cx = margin_sides + size / 2
|
|
|
|
|
+ wheel_cy = margin_top + (size) / 2
|
|
|
|
|
+
|
|
|
|
|
+ # ── Ring radii (proportions from the guide) ──────────────────────
|
|
|
|
|
+ outer_r = size / 2
|
|
|
|
|
+ tick_r = outer_r - 5
|
|
|
|
|
+ sign_r = outer_r - 20
|
|
|
|
|
+ house_r = sign_r - 30
|
|
|
|
|
+ planet_r = house_r - 45
|
|
|
|
|
+ inner_r = 35
|
|
|
|
|
+
|
|
|
|
|
+ # ── ASC longitude for rotation ───────────────────────────────────
|
|
|
|
|
+ asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ # ── SVG setup ────────────────────────────────────────────────────
|
|
|
|
|
+ dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
|
|
|
|
|
+ dwg.set_desc(title="Astro-MCP Chart Wheel")
|
|
|
|
|
+
|
|
|
|
|
+ dwg.defs.add(dwg.style(f"""
|
|
|
|
|
+ @font-face {{
|
|
|
|
|
+ font-family: 'Astronomicon';
|
|
|
|
|
+ src: url('{FONT_PATH}') format('truetype');
|
|
|
|
|
+ }}
|
|
|
|
|
+ .zf {{ font-family: 'Astronomicon', sans-serif; }}
|
|
|
|
|
+ .lbl {{ font-family: 'DejaVu Sans', 'Helvetica', Arial, sans-serif; }}
|
|
|
|
|
+ """))
|
|
|
|
|
+
|
|
|
|
|
+ # Background
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Title block ──────────────────────────────────────────────────
|
|
|
|
|
+ _render_title_block(dwg, chart_data, theme, canvas_w, margin_top, title, subtitle)
|
|
|
|
|
+
|
|
|
|
|
+ # ── Zodiac ring (rotated for ASC) ────────────────────────────────
|
|
|
|
|
+ for i, sign_name in enumerate(astrology.ZODIAC_SIGNS):
|
|
|
|
|
+ sign_start = (i * 30.0 - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ sign_end = ((i + 1) * 30.0 - asc_lon + 180.0) % 360.0
|
|
|
|
|
+
|
|
|
|
|
+ # Segment fill
|
|
|
|
|
+ if style != "minimal":
|
|
|
|
|
+ if color_mode != "bw":
|
|
|
|
|
+ element = astrology.SIGN_ELEMENTS.get(sign_name, "")
|
|
|
|
|
+ seg_color = theme.get(f"zodiac_{element}", theme["ring_fill"])
|
|
|
|
|
+ else:
|
|
|
|
|
+ seg_color = theme["ring_fill"]
|
|
|
|
|
+ # Draw pie slice
|
|
|
|
|
+ if sign_end > sign_start:
|
|
|
|
|
+ arc = _arc_path(wheel_cx, wheel_cy, sign_r, sign_start, sign_end)
|
|
|
|
|
+ else:
|
|
|
|
|
+ # Wraps through 0°
|
|
|
|
|
+ arc = _arc_path(wheel_cx, wheel_cy, sign_r, sign_start, sign_end + 360)
|
|
|
|
|
+ sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, sign_r, sign_start)
|
|
|
|
|
+ path_d = f"M {wheel_cx:.1f},{wheel_cy:.1f} L {sx:.1f},{sy:.1f} {arc} Z"
|
|
|
|
|
+ dwg.add(dwg.path(d=path_d, fill=seg_color, stroke="none"))
|
|
|
|
|
+
|
|
|
|
|
+ # Sign glyph at midpoint of segment
|
|
|
|
|
+ mid = (sign_start + sign_end) / 2
|
|
|
|
|
+ if abs(sign_end - sign_start) > 180:
|
|
|
|
|
+ mid = (sign_start + sign_end + 360) / 2 % 360
|
|
|
|
|
+ gx, gy = _polar_to_cartesian(wheel_cx, wheel_cy, (outer_r + sign_r) / 2, mid)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ _zodiac_glyph(sign_name), insert=(gx, gy),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="18px", fill=theme["sign_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # Ring borders
|
|
|
|
|
+ dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=outer_r, fill="none",
|
|
|
|
|
+ stroke=theme["ring_stroke"], stroke_width=2))
|
|
|
|
|
+ dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=sign_r, fill="none",
|
|
|
|
|
+ stroke=theme["ring_stroke"], stroke_width=1))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Degree tick marks on zodiac ring ─────────────────────────────
|
|
|
|
|
+ for deg_tick in range(0, 360, 5):
|
|
|
|
|
+ t_angle = (deg_tick - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ is_major = deg_tick % 30 == 0
|
|
|
|
|
+ t_inner = tick_r - (8 if is_major else 4)
|
|
|
|
|
+ t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, t_inner, t_angle)
|
|
|
|
|
+ t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, tick_r, t_angle)
|
|
|
|
|
+ dwg.add(dwg.line(
|
|
|
|
|
+ start=(t1x, t1y), end=(t2x, t2y),
|
|
|
|
|
+ stroke=theme["tick_major"] if is_major else theme["tick_minor"],
|
|
|
|
|
+ stroke_width=1.5 if is_major else 0.5,
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # ── House sectors ────────────────────────────────────────────────
|
|
|
|
|
+ for i, house in enumerate(houses):
|
|
|
|
|
+ cusp_lon = house.get("absolute_lon", i * 30.0)
|
|
|
|
|
+ next_cusp = houses[(i + 1) % 12].get("absolute_lon", ((i + 1) % 12) * 30.0)
|
|
|
|
|
+
|
|
|
|
|
+ c1 = (cusp_lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ c2 = (next_cusp - asc_lon + 180.0) % 360.0
|
|
|
|
|
+
|
|
|
|
|
+ # Sector fill (alternating)
|
|
|
|
|
+ if style != "minimal" and color_mode != "bw":
|
|
|
|
|
+ fill = theme["house_fill_alt"] if i % 2 == 0 else "none"
|
|
|
|
|
+ if fill != "none":
|
|
|
|
|
+ if c2 > c1:
|
|
|
|
|
+ arc = _arc_path(wheel_cx, wheel_cy, house_r, c1, c2)
|
|
|
|
|
+ else:
|
|
|
|
|
+ arc = _arc_path(wheel_cx, wheel_cy, house_r, c1, c2 + 360)
|
|
|
|
|
+ sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, house_r, c1)
|
|
|
|
|
+ path_d = f"M {wheel_cx:.1f},{wheel_cy:.1f} L {sx:.1f},{sy:.1f} {arc} Z"
|
|
|
|
|
+ dwg.add(dwg.path(d=path_d, fill=fill, stroke="none"))
|
|
|
|
|
+
|
|
|
|
|
+ # Cusp line
|
|
|
|
|
+ is_angular = house.get("house", i + 1) in ANGULAR_HOUSES
|
|
|
|
|
+ sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, house_r, c1)
|
|
|
|
|
+ ex, ey = _polar_to_cartesian(wheel_cx, wheel_cy, sign_r, c1)
|
|
|
|
|
+ dwg.add(dwg.line(
|
|
|
|
|
+ start=(sx, sy), end=(ex, ey),
|
|
|
|
|
+ stroke=theme["house_line"],
|
|
|
|
|
+ stroke_width=2.0 if is_angular else 0.8,
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # House number
|
|
|
|
|
+ mid_c = (c1 + c2) / 2
|
|
|
|
|
+ if abs(c2 - c1) > 180:
|
|
|
|
|
+ mid_c = (c1 + c2 + 360) / 2 % 360
|
|
|
|
|
+ hx, hy = _polar_to_cartesian(wheel_cx, wheel_cy, house_r - 14, mid_c)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ str(house.get("house", i + 1)), insert=(hx, hy),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="lbl", font_size="10px", fill=theme["degree_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Angle axis lines (ASC-DSC horizontal, MC-IC vertical) ────────
|
|
|
|
|
+ for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
|
|
|
|
|
+ lon = angles.get(key, {}).get("absolute_lon")
|
|
|
|
|
+ if lon is not None:
|
|
|
|
|
+ a = (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ ax1, ay1 = _polar_to_cartesian(wheel_cx, wheel_cy, inner_r, a)
|
|
|
|
|
+ ax2, ay2 = _polar_to_cartesian(wheel_cx, wheel_cy, outer_r + 5, a)
|
|
|
|
|
+ dwg.add(dwg.line(
|
|
|
|
|
+ start=(ax1, ay1), end=(ax2, ay2),
|
|
|
|
|
+ stroke=theme["axis_line"], stroke_width=1.5,
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Inner circle ─────────────────────────────────────────────────
|
|
|
|
|
+ dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=inner_r, fill=theme["background"],
|
|
|
|
|
+ stroke=theme["ring_stroke"], stroke_width=1.5))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Aspect lines ─────────────────────────────────────────────────
|
|
|
|
|
+ planet_lons = {p["body"]: p["absolute_lon"] for p in planets}
|
|
|
|
|
+ for asp in aspects:
|
|
|
|
|
+ b1 = asp.get("body1", "")
|
|
|
|
|
+ b2 = asp.get("body2", "")
|
|
|
|
|
+ asp_name = asp.get("aspect", "")
|
|
|
|
|
+ if b1 not in planet_lons or b2 not in planet_lons:
|
|
|
|
|
+ continue
|
|
|
|
|
+ a1 = (planet_lons[b1] - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ a2 = (planet_lons[b2] - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ r = planet_r - 5
|
|
|
|
|
+ x1, y1 = _polar_to_cartesian(wheel_cx, wheel_cy, r, a1)
|
|
|
|
|
+ x2, y2 = _polar_to_cartesian(wheel_cx, wheel_cy, r, a2)
|
|
|
|
|
+
|
|
|
|
|
+ if color_mode == "bw":
|
|
|
|
|
+ dash, width = BW_ASPECT_STYLES.get(asp_name, ("3,3", 1.0))
|
|
|
|
|
+ dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
|
|
|
|
|
+ stroke="#000", stroke_width=width, stroke_dasharray=dash))
|
|
|
|
|
+ else:
|
|
|
|
|
+ color_key, width = COLOR_ASPECT_STYLES.get(asp_name, ("aspect_minor", 1.0))
|
|
|
|
|
+ dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
|
|
|
|
|
+ stroke=theme.get(color_key, "#999"),
|
|
|
|
|
+ stroke_width=width, opacity=0.5))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Planet glyphs (with collision avoidance) ─────────────────────
|
|
|
|
|
+ _render_planets(dwg, planets, wheel_cx, wheel_cy, planet_r, asc_lon, theme, color_mode)
|
|
|
|
|
+
|
|
|
|
|
+ # ── Angle labels at outer edge ───────────────────────────────────
|
|
|
|
|
+ angle_labels = {
|
|
|
|
|
+ "ascendant": "ASC", "descendant": "DSC",
|
|
|
|
|
+ "midheaven": "MC", "imum_coeli": "IC",
|
|
|
|
|
+ }
|
|
|
|
|
+ for key, label in angle_labels.items():
|
|
|
|
|
+ lon = angles.get(key, {}).get("absolute_lon")
|
|
|
|
|
+ if lon is not None:
|
|
|
|
|
+ a = (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ ax, ay = _polar_to_cartesian(wheel_cx, wheel_cy, outer_r + 14, a)
|
|
|
|
|
+ deg_str = ""
|
|
|
|
|
+ if key == "ascendant":
|
|
|
|
|
+ asc_deg = angles["ascendant"].get("degree_within_sign", 0)
|
|
|
|
|
+ asc_sign = angles["ascendant"].get("sign_abbreviation", "")
|
|
|
|
|
+ deg_str = f" {_format_degree(asc_deg)} {asc_sign}"
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ label + deg_str, insert=(ax, ay),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="lbl", font_size="9px", fill=theme["angle_text"],
|
|
|
|
|
+ font_weight="bold",
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Footer ───────────────────────────────────────────────────────
|
|
|
|
|
+ footer_y = canvas_h - 8
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ f"astro-mcp v{__version__} • {chart_data.get('input', {}).get('house_system', 'placidus').capitalize()} • Tropical",
|
|
|
|
|
+ insert=(canvas_w / 2, footer_y),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="auto",
|
|
|
|
|
+ class_="lbl", font_size="7px", fill=theme["footer_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # ── Tables ───────────────────────────────────────────────────────
|
|
|
|
|
+ if has_tables:
|
|
|
|
|
+ if table_position == "right":
|
|
|
|
|
+ table_x = margin_sides * 2 + size
|
|
|
|
|
+ table_y = margin_top
|
|
|
|
|
+ _render_tables_inline(dwg, chart_data, theme, table_x, table_y, size - margin_top - margin_bottom,
|
|
|
|
|
+ include_planets, include_houses)
|
|
|
|
|
+ elif table_position == "below":
|
|
|
|
|
+ table_x = margin_sides
|
|
|
|
|
+ table_y = margin_top + size + 10
|
|
|
|
|
+ _render_tables_inline(dwg, chart_data, theme, table_x, table_y, size,
|
|
|
|
|
+ include_planets, include_houses)
|
|
|
|
|
+
|
|
|
|
|
+ return dwg.tostring()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Title block ───────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _render_title_block(
|
|
|
|
|
+ dwg: svgwrite.Drawing,
|
|
|
|
|
+ chart_data: dict,
|
|
|
|
|
+ theme: dict,
|
|
|
|
|
+ canvas_w: float,
|
|
|
|
|
+ margin_top: float,
|
|
|
|
|
+ title: str | None,
|
|
|
|
|
+ subtitle: str | None,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Render the title block above the wheel."""
|
|
|
|
|
+ cx = canvas_w / 2
|
|
|
|
|
+ inp = chart_data.get("input", {})
|
|
|
|
|
+ angles = chart_data.get("angles", {})
|
|
|
|
|
+
|
|
|
|
|
+ # Title line
|
|
|
|
|
+ if title is None:
|
|
|
|
|
+ chart_type = chart_data.get("chart_type", "natal").capitalize()
|
|
|
|
|
+ title = f"{chart_type} Chart"
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ title, insert=(cx, 14),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="auto",
|
|
|
|
|
+ class_="lbl", font_size="13px", fill=theme["title_text"],
|
|
|
|
|
+ font_weight="bold",
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # Subtitle: birth data
|
|
|
|
|
+ if subtitle is None:
|
|
|
|
|
+ parts = []
|
|
|
|
|
+ bdt = inp.get("birth_datetime", "")
|
|
|
|
|
+ if bdt:
|
|
|
|
|
+ try:
|
|
|
|
|
+ dt = bdt.replace("T", " ").split("+")[0].split("Z")[0]
|
|
|
|
|
+ parts.append(dt)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ parts.append(bdt)
|
|
|
|
|
+ lat = inp.get("latitude")
|
|
|
|
|
+ lon = inp.get("longitude")
|
|
|
|
|
+ if lat is not None and lon is not None:
|
|
|
|
|
+ lat_dir = "N" if lat >= 0 else "S"
|
|
|
|
|
+ lon_dir = "E" if lon >= 0 else "W"
|
|
|
|
|
+ parts.append(f"{abs(lat):.2f}°{lat_dir} {abs(lon):.2f}°{lon_dir}")
|
|
|
|
|
+ house_sys = inp.get("house_system", "placidus")
|
|
|
|
|
+ parts.append(house_sys.capitalize())
|
|
|
|
|
+ subtitle = " • ".join(parts)
|
|
|
|
|
+
|
|
|
|
|
+ if subtitle:
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ subtitle, insert=(cx, 28),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="auto",
|
|
|
|
|
+ class_="lbl", font_size="8px", fill=theme["data_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Planet rendering with collision avoidance ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _render_planets(
|
|
|
|
|
+ dwg: svgwrite.Drawing,
|
|
|
|
|
+ planets: list[dict],
|
|
|
|
|
+ cx: float, cy: float,
|
|
|
|
|
+ planet_r: float,
|
|
|
|
|
+ asc_lon: float,
|
|
|
|
|
+ theme: dict,
|
|
|
|
|
+ color_mode: str,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Render planet glyphs with basic collision avoidance."""
|
|
|
|
|
+ MIN_SPACING = 5.0 # minimum degrees between glyphs
|
|
|
|
|
+
|
|
|
|
|
+ # Calculate rotated positions
|
|
|
|
|
+ positions = []
|
|
|
|
|
+ for p in planets:
|
|
|
|
|
+ lon = p.get("absolute_lon", 0.0)
|
|
|
|
|
+ angle = (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ positions.append((angle, p))
|
|
|
|
|
+
|
|
|
|
|
+ # Sort by angle
|
|
|
|
|
+ positions.sort(key=lambda x: x[0])
|
|
|
|
|
+
|
|
|
|
|
+ # Detect collisions and spread
|
|
|
|
|
+ if len(positions) > 1:
|
|
|
|
|
+ for _pass in range(3): # few passes
|
|
|
|
|
+ for i in range(len(positions)):
|
|
|
|
|
+ angle_i, p_i = positions[i]
|
|
|
|
|
+ angle_next, p_next = positions[(i + 1) % len(positions)]
|
|
|
|
|
+ diff = (angle_next - angle_i) % 360.0
|
|
|
|
|
+ if 0 < diff < MIN_SPACING:
|
|
|
|
|
+ shift = (MIN_SPACING - diff) / 2.0
|
|
|
|
|
+ positions[i] = ((angle_i - shift) % 360.0, p_i)
|
|
|
|
|
+ positions[(i + 1) % len(positions)] = ((angle_next + shift) % 360.0, p_next)
|
|
|
|
|
+
|
|
|
|
|
+ # Render
|
|
|
|
|
+ for angle, p in positions:
|
|
|
|
|
+ body = p["body"]
|
|
|
|
|
+ retro = p.get("retrograde", False)
|
|
|
|
|
+ deg = p.get("degree_within_sign", 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ px, py = _polar_to_cartesian(cx, cy, planet_r, angle)
|
|
|
|
|
+ g_char = _planet_glyph(body)
|
|
|
|
|
+
|
|
|
|
|
+ # Glyph
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ g_char, insert=(px, py),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="16px", fill=theme["planet_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # Retrograde marker
|
|
|
|
|
+ if retro:
|
|
|
|
|
+ rx, ry = _polar_to_cartesian(cx, cy, planet_r + 12, angle)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ RETROGRADE, insert=(rx, ry),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="8px", fill=theme["planet_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # Degree label (combined with glyph: "Q 14°22'")
|
|
|
|
|
+ lx, ly = _polar_to_cartesian(cx, cy, planet_r + 20, angle)
|
|
|
|
|
+ deg_str = _format_degree(deg)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ f"{g_char} {deg_str}", insert=(lx, ly),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="lbl", font_size="6.5px", fill=theme["degree_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Tables ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def _estimate_table_height(chart_data: dict, include_planets: bool, include_houses: bool) -> int:
|
|
|
|
|
+ h = 0
|
|
|
|
|
+ if include_planets:
|
|
|
|
|
+ n = len(chart_data.get("planets", []))
|
|
|
|
|
+ h += 20 + n * 16 + 10
|
|
|
|
|
+ if include_houses:
|
|
|
|
|
+ h += 20 + 12 * 16 + 10
|
|
|
|
|
+ return h
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_tables_inline(
|
|
|
|
|
+ dwg: svgwrite.Drawing,
|
|
|
|
|
+ chart_data: dict,
|
|
|
|
|
+ theme: dict,
|
|
|
|
|
+ x: float, y: float,
|
|
|
|
|
+ max_h: float,
|
|
|
|
|
+ include_planets: bool,
|
|
|
|
|
+ include_houses: bool,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ """Render data tables at the given position."""
|
|
|
|
|
+ cy = y
|
|
|
|
|
+
|
|
|
|
|
+ if include_planets:
|
|
|
|
|
+ cy += _render_planet_table(dwg, chart_data, theme, x, cy, max_h)
|
|
|
|
|
+ cy += 10
|
|
|
|
|
+
|
|
|
|
|
+ if include_houses:
|
|
|
|
|
+ _render_house_table(dwg, chart_data, theme, x, cy, max_h)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_planet_table(
|
|
|
|
|
+ dwg: svgwrite.Drawing, chart_data: dict, theme: dict,
|
|
|
|
|
+ x: float, y: float, max_w: float,
|
|
|
|
|
+) -> float:
|
|
|
|
|
+ """Render planet positions table. Returns height used."""
|
|
|
|
|
+ planets = chart_data.get("planets", [])
|
|
|
|
|
+ row_h = 15
|
|
|
|
|
+ header_h = 18
|
|
|
|
|
+ n = len(planets)
|
|
|
|
|
+ h = header_h + n * row_h + 4
|
|
|
|
|
+
|
|
|
|
|
+ # Background
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
|
|
|
|
|
+ stroke=theme["table_line"], stroke_width=0.5))
|
|
|
|
|
+
|
|
|
|
|
+ # Header
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(x, y), size=(max_w, header_h), fill=theme["table_header"]))
|
|
|
|
|
+ cols = [("Planet", 50), ("Sign", 45), ("Degree", 55), ("House", 35), ("Rx", 20)]
|
|
|
|
|
+ cx_pos = x + 5
|
|
|
|
|
+ for hdr, cw in cols:
|
|
|
|
|
+ dwg.add(dwg.text(hdr, insert=(cx_pos, y + 12),
|
|
|
|
|
+ class_="lbl", font_size="8px", fill="#fff", font_weight="bold"))
|
|
|
|
|
+ cx_pos += cw
|
|
|
|
|
+
|
|
|
|
|
+ # Rows
|
|
|
|
|
+ for j, p in enumerate(planets):
|
|
|
|
|
+ ry = y + header_h + j * row_h
|
|
|
|
|
+ if j % 2 == 1:
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(x, ry), size=(max_w, row_h), fill=theme["table_row_alt"]))
|
|
|
|
|
+
|
|
|
|
|
+ deg = p.get("degree_within_sign", 0)
|
|
|
|
|
+ sign_abbr = p.get("sign_abbreviation", p.get("sign", ""))[:2]
|
|
|
|
|
+ values = [
|
|
|
|
|
+ _planet_glyph(p["body"]),
|
|
|
|
|
+ sign_abbr,
|
|
|
|
|
+ _format_degree(deg),
|
|
|
|
|
+ str(p.get("house", "")),
|
|
|
|
|
+ RETROGRADE if p.get("retrograde") else "",
|
|
|
|
|
+ ]
|
|
|
|
|
+ cx_pos = x + 5
|
|
|
|
|
+ for val, (_, cw) in zip(values, cols):
|
|
|
|
|
+ dwg.add(dwg.text(val, insert=(cx_pos, ry + 10),
|
|
|
|
|
+ class_="lbl", font_size="8px", fill=theme["table_text"]))
|
|
|
|
|
+ cx_pos += cw
|
|
|
|
|
+
|
|
|
|
|
+ return h
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _render_house_table(
|
|
|
|
|
+ dwg: svgwrite.Drawing, chart_data: dict, theme: dict,
|
|
|
|
|
+ x: float, y: float, max_w: float,
|
|
|
|
|
+) -> float:
|
|
|
|
|
+ """Render house cusps table. Returns height used."""
|
|
|
|
|
+ houses = chart_data.get("houses", [])
|
|
|
|
|
+ row_h = 15
|
|
|
|
|
+ header_h = 18
|
|
|
|
|
+ n = len(houses)
|
|
|
|
|
+ h = header_h + n * row_h + 4
|
|
|
|
|
+
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
|
|
|
|
|
+ stroke=theme["table_line"], stroke_width=0.5))
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(x, y), size=(max_w, header_h), fill=theme["table_header"]))
|
|
|
|
|
+
|
|
|
|
|
+ cols = [("House", 40), ("Sign", 45), ("Cusp", 55)]
|
|
|
|
|
+ cx_pos = x + 5
|
|
|
|
|
+ for hdr, cw in cols:
|
|
|
|
|
+ dwg.add(dwg.text(hdr, insert=(cx_pos, y + 12),
|
|
|
|
|
+ class_="lbl", font_size="8px", fill="#fff", font_weight="bold"))
|
|
|
|
|
+ cx_pos += cw
|
|
|
|
|
+
|
|
|
|
|
+ for j, hd in enumerate(houses):
|
|
|
|
|
+ ry = y + header_h + j * row_h
|
|
|
|
|
+ if j % 2 == 1:
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(x, ry), size=(max_w, row_h), fill=theme["table_row_alt"]))
|
|
|
|
|
+ cusp_deg = hd.get("degree", 0)
|
|
|
|
|
+ sign_abbr = hd.get("abbreviation", hd.get("sign", ""))[:2]
|
|
|
|
|
+ values = [str(hd.get("house", j + 1)), sign_abbr, _format_degree(cusp_deg)]
|
|
|
|
|
+ cx_pos = x + 5
|
|
|
|
|
+ for val, (_, cw) in zip(values, cols):
|
|
|
|
|
+ dwg.add(dwg.text(val, insert=(cx_pos, ry + 10),
|
|
|
|
|
+ class_="lbl", font_size="8px", fill=theme["table_text"]))
|
|
|
|
|
+ cx_pos += cw
|
|
|
|
|
+
|
|
|
|
|
+ return h
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Transit bi-wheel renderer ─────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def render_transit_wheel(
|
|
|
|
|
+ chart_data: dict[str, Any],
|
|
|
|
|
+ style: str = "modern",
|
|
|
|
|
+ color_mode: str = "color",
|
|
|
|
|
+ size: int = 600,
|
|
|
|
|
+ table_position: str = "none",
|
|
|
|
|
+ **kwargs,
|
|
|
|
|
+) -> str:
|
|
|
|
|
+ """Render a transit chart as bi-wheel (natal inner, transit outer)."""
|
|
|
|
|
+ theme = THEMES.get(color_mode, THEMES["color"])
|
|
|
|
|
+ natal = chart_data.get("natal_planets", [])
|
|
|
|
|
+ transit = chart_data.get("transiting_planets", [])
|
|
|
|
|
+ houses = chart_data.get("houses", [])
|
|
|
|
|
+ aspects = chart_data.get("aspects", [])
|
|
|
|
|
+ angles = chart_data.get("angles", {})
|
|
|
|
|
+ asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
|
|
|
|
|
+
|
|
|
|
|
+ margin_top = 50
|
|
|
|
|
+ margin_bottom = 30
|
|
|
|
|
+ margin_sides = 20
|
|
|
|
|
+ canvas_w = size + margin_sides * 2
|
|
|
|
|
+ canvas_h = size + margin_top + margin_bottom
|
|
|
|
|
+ cx = canvas_w / 2
|
|
|
|
|
+ cy = margin_top + size / 2
|
|
|
|
|
+
|
|
|
|
|
+ outer_r = size / 2
|
|
|
|
|
+ transit_r = outer_r - 25
|
|
|
|
|
+ natal_outer_r = transit_r - 30
|
|
|
|
|
+ natal_r = natal_outer_r - 30
|
|
|
|
|
+ inner_r = 30
|
|
|
|
|
+
|
|
|
|
|
+ dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
|
|
|
|
|
+ dwg.defs.add(dwg.style(f"""
|
|
|
|
|
+ @font-face {{ font-family: 'Astronomicon'; src: url('{FONT_PATH}') format('truetype'); }}
|
|
|
|
|
+ .zf {{ font-family: 'Astronomicon', sans-serif; }}
|
|
|
|
|
+ .lbl {{ font-family: 'DejaVu Sans', 'Helvetica', Arial, sans-serif; }}
|
|
|
|
|
+ """))
|
|
|
|
|
+ dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
|
|
|
|
|
+
|
|
|
|
|
+ # Title
|
|
|
|
|
+ _render_title_block(dwg, chart_data, theme, canvas_w, margin_top, "Transit Chart", None)
|
|
|
|
|
+
|
|
|
|
|
+ # Zodiac ring
|
|
|
|
|
+ for i, sign_name in enumerate(astrology.ZODIAC_SIGNS):
|
|
|
|
|
+ sign_start = (i * 30.0 - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ sign_end = ((i + 1) * 30.0 - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ mid = (sign_start + sign_end) / 2
|
|
|
|
|
+ if abs(sign_end - sign_start) > 180:
|
|
|
|
|
+ mid = (sign_start + sign_end + 360) / 2 % 360
|
|
|
|
|
+ gx, gy = _polar_to_cartesian(cx, cy, (outer_r + transit_r) / 2, mid)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ _zodiac_glyph(sign_name), insert=(gx, gy),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="16px", fill=theme["sign_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ dwg.add(dwg.circle(center=(cx, cy), r=outer_r, fill="none", stroke=theme["ring_stroke"], stroke_width=2))
|
|
|
|
|
+ dwg.add(dwg.circle(center=(cx, cy), r=transit_r, fill="none", stroke=theme["ring_stroke"], stroke_width=0.5))
|
|
|
|
|
+ dwg.add(dwg.circle(center=(cx, cy), r=natal_outer_r, fill="none", stroke=theme["ring_stroke"], stroke_width=0.5))
|
|
|
|
|
+
|
|
|
|
|
+ # House cusps
|
|
|
|
|
+ for i, house in enumerate(houses):
|
|
|
|
|
+ cusp_lon = house.get("absolute_lon", i * 30.0)
|
|
|
|
|
+ c = (cusp_lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ sx, sy = _polar_to_cartesian(cx, cy, natal_outer_r, c)
|
|
|
|
|
+ ex, ey = _polar_to_cartesian(cx, cy, outer_r, c)
|
|
|
|
|
+ is_ang = house.get("house", i + 1) in ANGULAR_HOUSES
|
|
|
|
|
+ dwg.add(dwg.line(start=(sx, sy), end=(ex, ey),
|
|
|
|
|
+ stroke=theme["house_line"], stroke_width=2.0 if is_ang else 0.8))
|
|
|
|
|
+
|
|
|
|
|
+ # Transit-to-natal aspect lines
|
|
|
|
|
+ natal_lons = {p["body"]: p["absolute_lon"] for p in natal}
|
|
|
|
|
+ transit_lons = {p["body"]: p["absolute_lon"] for p in transit}
|
|
|
|
|
+ for asp in aspects:
|
|
|
|
|
+ t_body = asp.get("transiting", "")
|
|
|
|
|
+ n_body = asp.get("natal", "")
|
|
|
|
|
+ asp_name = asp.get("aspect", "")
|
|
|
|
|
+ if t_body not in transit_lons or n_body not in natal_lons:
|
|
|
|
|
+ continue
|
|
|
|
|
+ a1 = (transit_lons[t_body] - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ a2 = (natal_lons[n_body] - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ r_mid = (transit_r + natal_r) / 2
|
|
|
|
|
+ x1, y1 = _polar_to_cartesian(cx, cy, r_mid, a1)
|
|
|
|
|
+ x2, y2 = _polar_to_cartesian(cx, cy, r_mid, a2)
|
|
|
|
|
+ if color_mode == "bw":
|
|
|
|
|
+ dash, width = BW_ASPECT_STYLES.get(asp_name, ("3,3", 1.0))
|
|
|
|
|
+ dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
|
|
|
|
|
+ stroke="#000", stroke_width=width, stroke_dasharray=dash))
|
|
|
|
|
+ else:
|
|
|
|
|
+ color_key, width = COLOR_ASPECT_STYLES.get(asp_name, ("aspect_minor", 1.0))
|
|
|
|
|
+ dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
|
|
|
|
|
+ stroke=theme.get(color_key, "#999"),
|
|
|
|
|
+ stroke_width=width, opacity=0.4))
|
|
|
|
|
+
|
|
|
|
|
+ # Transit planets (outer ring)
|
|
|
|
|
+ for p in transit:
|
|
|
|
|
+ lon = p.get("absolute_lon", 0.0)
|
|
|
|
|
+ angle = (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ px, py = _polar_to_cartesian(cx, cy, transit_r, angle)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ _planet_glyph(p["body"]), insert=(px, py),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="14px", fill=theme["planet_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ # Natal planets (inner ring)
|
|
|
|
|
+ for p in natal:
|
|
|
|
|
+ lon = p.get("absolute_lon", 0.0)
|
|
|
|
|
+ angle = (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ px, py = _polar_to_cartesian(cx, cy, natal_r, angle)
|
|
|
|
|
+ retro = p.get("retrograde", False)
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ _planet_glyph(p["body"]), insert=(px, py),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="14px", fill=theme["planet_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+ if retro:
|
|
|
|
|
+ rx, ry = _polar_to_cartesian(cx, cy, natal_r + 10, angle)
|
|
|
|
|
+ dwg.add(dwg.text(RETROGRADE, insert=(rx, ry),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="zf", font_size="7px", fill=theme["planet_text"]))
|
|
|
|
|
+
|
|
|
|
|
+ # Inner circle + angle labels
|
|
|
|
|
+ dwg.add(dwg.circle(center=(cx, cy), r=inner_r, fill=theme["background"],
|
|
|
|
|
+ stroke=theme["ring_stroke"], stroke_width=1.5))
|
|
|
|
|
+ angle_labels = {"ascendant": "ASC", "descendant": "DSC", "midheaven": "MC", "imum_coeli": "IC"}
|
|
|
|
|
+ for key, label in angle_labels.items():
|
|
|
|
|
+ lon = angles.get(key, {}).get("absolute_lon")
|
|
|
|
|
+ if lon is not None:
|
|
|
|
|
+ a = (lon - asc_lon + 180.0) % 360.0
|
|
|
|
|
+ ax, ay = _polar_to_cartesian(cx, cy, inner_r - 6, a)
|
|
|
|
|
+ dwg.add(dwg.text(label, insert=(ax, ay),
|
|
|
|
|
+ text_anchor="middle", dominant_baseline="central",
|
|
|
|
|
+ class_="lbl", font_size="8px", fill=theme["angle_text"],
|
|
|
|
|
+ font_weight="bold"))
|
|
|
|
|
+
|
|
|
|
|
+ # Footer
|
|
|
|
|
+ dwg.add(dwg.text(
|
|
|
|
|
+ f"astro-mcp v{__version__} • {chart_data.get('input', {}).get('house_system', 'placidus').capitalize()} • Tropical",
|
|
|
|
|
+ insert=(canvas_w / 2, canvas_h - 8),
|
|
|
|
|
+ text_anchor="middle", class_="lbl", font_size="7px", fill=theme["footer_text"],
|
|
|
|
|
+ ))
|
|
|
|
|
+
|
|
|
|
|
+ return dwg.tostring()
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Synastry renderer (stub) ──────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+def render_synastry_wheel(chart_data, **kwargs):
|
|
|
|
|
+ """Render synastry chart. TODO: full dual-wheel implementation."""
|
|
|
|
|
+ return render_natal_wheel(chart_data, **kwargs)
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+# ── Dispatch ──────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+RENDERERS = {
|
|
|
|
|
+ "natal": render_natal_wheel,
|
|
|
|
|
+ "transit": render_transit_wheel,
|
|
|
|
|
+ "synastry": render_synastry_wheel,
|
|
|
|
|
+ "composite": render_natal_wheel,
|
|
|
|
|
+ "davison": render_natal_wheel,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def render(chart_data: dict[str, Any], **kwargs) -> str:
|
|
|
|
|
+ """Render a chart wheel. Auto-detects chart type from chart_data."""
|
|
|
|
|
+ chart_type = chart_data.get("chart_type", "natal")
|
|
|
|
|
+ renderer = RENDERERS.get(chart_type, render_natal_wheel)
|
|
|
|
|
+ return renderer(chart_data, **kwargs)
|