""" 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 Coordinate convention --------------------- _polar_to_cartesian(cx, cy, r, angle_deg): angle 0 = 12 o'clock (top) angle 90 = 3 o'clock (right) angle 180 = 6 o'clock (bottom) angle 270 = 9 o'clock (left) <- ASC always here Rotation formula -> _svg_angle(lon, asc_lon): svg_angle = (lon - asc_lon + 270) % 360 This maps ASC longitude -> 270° (left) and zodiac increases CCW. """ 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 from .chart_styles import ( THEMES, BW_ASPECT_STYLES, COLOR_ASPECT_STYLES, ANGULAR_HOUSES, font_face_css, r as _r, _R_OUTER, _R_TICK_OUTER, _R_TICK_INNER_5, _R_TICK_INNER_1, _R_ZODIAC_OUTER, _R_ZODIAC_GLYPH, _R_ZODIAC_INNER, _R_HOUSE_NUM, _R_CUSP_INNER, _R_PLANET, _R_PLANET_DEG, _R_CONNECTOR, _R_CENTER, _R_ASPECT, ) from .chart_helpers import svg_to_image, render_envelope logger = logging.getLogger("astro-mcp.chart_renderer") # ── Geometry helpers ────────────────────────────────────────────────── def _polar_to_cartesian(cx: float, cy: float, r: float, angle_deg: float) -> tuple[float, float]: """Polar to cartesian. angle 0=top (12 o'clock), 90=right, 180=bottom, 270=left. Clockwise. """ rad = math.radians(angle_deg) return cx + r * math.sin(rad), cy - r * math.cos(rad) def _svg_angle(lon: float, asc_lon: float) -> float: """Convert ecliptic longitude to SVG angle given the ASC longitude. Maps ASC longitude -> 270° (9 o'clock, left). Zodiac increases counter-clockwise (visually) as angle decreases, which in our clockwise-angle system means we subtract from the offset. """ return (asc_lon - lon + 270.0) % 360.0 def _arc_path(cx: float, cy: float, r: float, start_deg: float, end_deg: float, sweep: int = 1) -> str: """SVG arc path from start_deg to end_deg. sweep=1: clockwise (in SVG angle space, which is CW visually) sweep=0: counter-clockwise Automatically determines large-arc-flag based on sweep direction. """ start = _polar_to_cartesian(cx, cy, r, start_deg) end = _polar_to_cartesian(cx, cy, r, end_deg) if sweep == 1: diff = (end_deg - start_deg) % 360.0 else: diff = (start_deg - end_deg) % 360.0 large = 1 if diff > 180 else 0 return (f"M {start[0]:.2f},{start[1]:.2f} " f"A {r:.2f},{r:.2f} 0 {large},{sweep} {end[0]:.2f},{end[1]:.2f}") # ── 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}\u00b0{m:02d}\u2019" def _to_roman(num: int) -> str: """Convert an integer (1-12) to a Roman numeral string.""" roman_map = { 1: "I", 2: "II", 3: "III", 4: "IV", 5: "V", 6: "VI", 7: "VII", 8: "VIII", 9: "IX", 10: "X", 11: "XI", 12: "XII", } return roman_map.get(num, str(num)) # ── Main natal wheel renderer ───────────────────────────────────────── def render_natal_wheel( chart_data: dict[str, Any], style: str = "modern", color_mode: str = "color", size: int = 700, table_position: str = "none", include_planets: bool = False, include_houses: bool = False, title: str | None = None, subtitle: str | None = None, format: str = "svg", ) -> dict[str, Any]: """Render a natal chart wheel as SVG (or raster image). The wheel fills a square canvas. ASC is always at 9 o'clock (left). MC is placed at its actual ecliptic longitude — it is NOT forced to 12 o'clock (that would only be true in Equal House). Args: chart_data: Output from calculate_natal_chart. style: "modern" | "minimal" color_mode: "color" | "bw" | "dark" size: Canvas side length in pixels (square). table_position: "none", "below", or "right". include_planets: Include planet data table. include_houses: Include house cusp table. title: Override auto-generated title line. subtitle: Override auto-generated subtitle line. format: Output format — "svg", "png", or "jpg". Default "svg". Returns: Dict with content, format, content_type, width, height, and included. """ 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", {}) # ── Canvas & wheel geometry ────────────────────────────────────── has_tables = table_position in ("below", "right") and (include_planets or include_houses) if table_position == "right" and has_tables: table_w = 230 canvas_w = size + table_w canvas_h = size elif table_position == "below" and has_tables: table_h = _estimate_table_height(chart_data, include_planets, include_houses) canvas_w = size canvas_h = size + table_h + 8 else: canvas_w = size canvas_h = size # Wheel is always perfectly square, centered in the first 'size x size' area wheel_cx = size / 2 wheel_cy = size / 2 outer_r = size / 2 - 20 # margin for angle labels and axis lines # ── 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(font_face_css())) # Background dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"])) # ── Zodiac ring ────────────────────────────────────────────────── r_zod_out = _r(outer_r, _R_ZODIAC_OUTER) r_zod_in = _r(outer_r, _R_ZODIAC_INNER) r_zod_mid = _r(outer_r, _R_ZODIAC_GLYPH) for i, sign_name in enumerate(astrology.ZODIAC_SIGNS): seg_start_lon = i * 30.0 seg_end_lon = (i + 1) * 30.0 a_start = _svg_angle(seg_start_lon, asc_lon) a_end = _svg_angle(seg_end_lon, asc_lon) # Segment fill (element colour in colour mode, alternating grey in bw) if style != "minimal": if color_mode == "bw": seg_color = "#e8e8e8" if i % 2 == 0 else "#f8f8f8" else: element = astrology.SIGN_ELEMENTS.get(sign_name, "") seg_color = theme.get(f"zodiac_{element}", theme["ring_fill"]) # Zodiac band segment fill (between outer and inner zodiac edges) # Path: outer arc (CCW) → radial line → inner arc (CW) → radial line → close sx_out, sy_out = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_start) ex_out, ey_out = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_end) sx_in, sy_in = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a_start) ex_in, ey_in = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a_end) path_d = ( f"M {sx_out:.2f},{sy_out:.2f} " f"A {r_zod_out:.2f},{r_zod_out:.2f} 0 0,0 {ex_out:.2f},{ey_out:.2f} " f"L {ex_in:.2f},{ey_in:.2f} " f"A {r_zod_in:.2f},{r_zod_in:.2f} 0 0,1 {sx_in:.2f},{sy_in:.2f} Z" ) dwg.add(dwg.path(d=path_d, fill=seg_color, stroke="none")) # Sign glyph at midpoint of segment mid_a = _svg_angle(seg_start_lon + 15.0, asc_lon) gx, gy = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_mid, mid_a) dwg.add(dwg.text( _zodiac_glyph(sign_name), insert=(gx, gy), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="26px", fill=theme["sign_text"], )) # Zodiac band border circles dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=r_zod_out, fill="none", stroke=theme["ring_stroke"], stroke_width=1.5)) dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=r_zod_in, fill="none", stroke=theme["ring_stroke"], stroke_width=1.0)) # Zodiac sign boundary lines (full length from outer to inner edge) for i in range(12): a = _svg_angle(i * 30.0, asc_lon) sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a) ex, ey = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a) dwg.add(dwg.line(start=(sx, sy), end=(ex, ey), stroke=theme["ring_stroke"], stroke_width=0.8)) # ── Degree tick marks on outer zodiac ring ─────────────────────── r_tick_out = _r(outer_r, _R_TICK_OUTER) for deg_tick in range(0, 360): t_angle = _svg_angle(float(deg_tick), asc_lon) is_sign = deg_tick % 30 == 0 is_10 = deg_tick % 10 == 0 is_5 = deg_tick % 5 == 0 if is_sign or is_10: tick_len = 10 stroke = theme["tick_major"] width = 1.5 elif is_5: tick_len = 7 stroke = theme["tick_major"] width = 1.0 else: tick_len = 3 stroke = theme["tick_minor"] width = 0.8 # Ticks extend OUTWARD from the zodiac outer edge t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, t_angle) t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out + tick_len, t_angle) dwg.add(dwg.line(start=(t1x, t1y), end=(t2x, t2y), stroke=stroke, stroke_width=width)) # ── House sectors ──────────────────────────────────────────────── r_cusp_in = _r(outer_r, _R_CUSP_INNER) r_hnum = _r(outer_r, _R_HOUSE_NUM) 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 = _svg_angle(cusp_lon, asc_lon) c2 = _svg_angle(next_cusp, asc_lon) # Cusp line from inner zodiac edge inward is_angular = house.get("house", i + 1) in ANGULAR_HOUSES sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, r_cusp_in, c1) ex, ey = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, 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 label — midpoint of sector going CCW (decreasing angle) diff = (c1 - c2) % 360.0 # CCW distance from c1 to c2 mid_c = (c1 - diff / 2.0) % 360.0 hx, hy = _polar_to_cartesian(wheel_cx, wheel_cy, r_hnum, mid_c) house_num = house.get("house", i + 1) dwg.add(dwg.text( _to_roman(house_num), insert=(hx, hy), text_anchor="middle", dominant_baseline="central", class_="lbl", font_size="10px", font_style="italic", fill=theme["degree_text"], )) # ── Angle axis lines (ASC-DSC and MC-IC) ───────────────────────── # Extend from center circle through zodiac band and beyond outer rim for key in ("ascendant", "midheaven", "descendant", "imum_coeli"): lon = angles.get(key, {}).get("absolute_lon") if lon is not None: a = _svg_angle(lon, asc_lon) ax1, ay1 = _polar_to_cartesian(wheel_cx, wheel_cy, _r(outer_r, _R_CENTER), a) ax2, ay2 = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out + 24, a) dwg.add(dwg.line( start=(ax1, ay1), end=(ax2, ay2), stroke=theme["axis_line"], stroke_width=1.5, )) # ── Center circle ───────────────────────────────────────────────── r_center = _r(outer_r, _R_CENTER) dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=r_center, fill=theme["background"], stroke=theme["ring_stroke"], stroke_width=1.0)) # ── Aspect lines ───────────────────────────────────────────────── planet_lons = {p["body"]: p["absolute_lon"] for p in planets} r_asp = _r(outer_r, _R_ASPECT) 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 = _svg_angle(planet_lons[b1], asc_lon) a2 = _svg_angle(planet_lons[b2], asc_lon) x1, y1 = _polar_to_cartesian(wheel_cx, wheel_cy, r_asp, a1) x2, y2 = _polar_to_cartesian(wheel_cx, wheel_cy, r_asp, a2) if color_mode == "bw": dash, width = BW_ASPECT_STYLES.get(asp_name, ("2,3", 0.7)) extra = {"stroke_dasharray": dash} if dash != "none" else {} dwg.add(dwg.line(start=(x1, y1), end=(x2, y2), stroke="#000", stroke_width=width, **extra)) else: color_key, width = COLOR_ASPECT_STYLES.get(asp_name, ("aspect_minor", 0.7)) dwg.add(dwg.line(start=(x1, y1), end=(x2, y2), stroke=theme.get(color_key, "#999"), stroke_width=width, opacity="0.55")) # ── Planet glyphs with collision avoidance and connector ticks ─── _render_planets(dwg, planets, wheel_cx, wheel_cy, outer_r, asc_lon, theme, color_mode) # ── Planet position ticks on inner zodiac edge ──────────────────── for p in planets: lon = p.get("absolute_lon", 0.0) a = _svg_angle(lon, asc_lon) t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in - 3, a) t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in + 3, a) dwg.add(dwg.line(start=(t1x, t1y), end=(t2x, t2y), stroke=theme["ring_stroke"], stroke_width=1.5)) # ── Angle glyphs at the end of extended axis lines ──────────────── angle_chars = { "ascendant": glyph("ascendant"), "descendant": glyph("descendant"), "midheaven": glyph("midheaven"), "imum_coeli": glyph("imum_coeli"), } r_angle_lbl = r_zod_out + 22 for key in ("ascendant", "midheaven", "descendant", "imum_coeli"): lon = angles.get(key, {}).get("absolute_lon") if lon is None: continue a = _svg_angle(lon, asc_lon) nx, ny = _polar_to_cartesian(wheel_cx, wheel_cy, r_angle_lbl, a) g_char = angle_chars.get(key, "") # Offset glyph tangentially to avoid overlapping with the axis line # ASC up, DSC down, MC right, IC left offset = 8 rad = math.radians(a) # Tangential direction: rotate 90° CW from radial tx = math.cos(rad) * offset ty = math.sin(rad) * offset nx += tx ny += ty dwg.add(dwg.text( g_char, insert=(nx, ny), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="22px", fill=theme["angle_text"], )) # ── Title block — top-left corner ──────────────────────────────── _render_title_corner(dwg, chart_data, theme, title, subtitle) # ── Footer — bottom-left corner ─────────────────────────────────── footer_y = canvas_h - 4 house_sys = inp.get("house_system", "placidus").capitalize() dwg.add(dwg.text( f"astro-mcp v{__version__} \u2022 {house_sys} \u2022 Tropical", insert=(5, footer_y), text_anchor="start", dominant_baseline="auto", class_="lbl", font_size="7px", fill=theme["footer_text"], )) # ── Tables ─────────────────────────────────────────────────────── if has_tables: if table_position == "right": table_x = size + 8 table_y = 8 _render_tables_inline(dwg, chart_data, theme, table_x, table_y, canvas_h - 16, include_planets, include_houses) elif table_position == "below": table_x = 8 table_y = size + 8 _render_tables_inline(dwg, chart_data, theme, table_x, table_y, size - 16, include_planets, include_houses) svg_str = dwg.tostring() return render_envelope(svg_to_image(svg_str, format, size), format, size) # ── Title block — corner ────────────────────────────────────────────── def _render_title_corner( dwg: svgwrite.Drawing, chart_data: dict, theme: dict, title: str | None, subtitle: str | None, ) -> None: """Render title block in the top-left corner using a grouped layout.""" inp = chart_data.get("input", {}) angles = chart_data.get("angles", {}) if title is None: chart_type = chart_data.get("chart_type", "natal").capitalize() name = inp.get("name", "") title = f"{name}" if name else f"{chart_type} Chart" # Build title lines as (text, font_size, fill, bold) tuples lines: list[tuple[str, str, str, bool]] = [] # Small "Natal Chart for:" label if chart_data.get("chart_type", "natal") == "natal" and title: lines.append(("Natal Chart for:", "8px", theme["footer_text"], False)) # Name (bold, larger) lines.append((title, "14px", theme["title_text"], True)) # Birth datetime bdt = inp.get("birth_datetime", "") if bdt: try: from datetime import datetime, timezone dt = datetime.fromisoformat(bdt.replace("Z", "+00:00")) dt_str = dt.strftime("%d %B %Y %H:%M") if dt.tzinfo and dt.tzinfo != timezone.utc: tz_name = dt.strftime("%Z") if tz_name and tz_name != "UTC": dt_str += f" {tz_name}" else: # Naive datetime (local time from DB) — use the timezone field tz_name = inp.get("timezone", "") if tz_name: dt_str += f" {tz_name}" lines.append((dt_str, "8px", theme["data_text"], False)) except Exception: pass # Birthplace bp = inp.get("birthplace", "") if bp: lines.append((bp, "8px", theme["data_text"], False)) # Lat/Lon 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" lines.append((f"{abs(lat):.4f}\u00b0{lat_dir} {abs(lon):.4f}\u00b0{lon_dir}", "7.5px", theme["data_text"], False)) if subtitle: lines.append((subtitle, "7.5px", theme["data_text"], False)) # Render all lines inside a group with consistent line spacing x = 8 y_start = 12 line_gap = 4 # extra gap between lines in px # Calculate positions: each line's y = previous y + previous font_size + line_gap g = dwg.g(class_="title-block") y = y_start for i, (text, font_size, fill, bold) in enumerate(lines): # Parse font_size to float for line height calculation fs = float(font_size.replace("px", "")) # Use hanging baseline so y is the top of the text t = dwg.text( text, insert=(x, y), text_anchor="start", dominant_baseline="hanging", class_="lbl", font_size=font_size, fill=fill, ) if bold: t.attribs["font-weight"] = "bold" g.add(t) y += fs + line_gap dwg.add(g) # ── Planet rendering with collision avoidance ───────────────────────── def _render_planets( dwg: svgwrite.Drawing, planets: list[dict], cx: float, cy: float, outer_r: float, asc_lon: float, theme: dict, color_mode: str, ) -> None: """Render planet glyphs, degree labels, and connector tick lines.""" MIN_SPACING = 6.0 # minimum degrees between glyph centres r_planet = _r(outer_r, _R_PLANET) r_deg_lbl = _r(outer_r, _R_PLANET_DEG) r_conn = _r(outer_r, _R_CONNECTOR) # connector tick inner end r_zod_in = _r(outer_r, _R_ZODIAC_INNER) # Calculate display angles (may be nudged for spacing) positions: list[tuple[float, dict]] = [] for p in planets: lon = p.get("absolute_lon", 0.0) angle = _svg_angle(lon, asc_lon) positions.append((angle, p)) positions.sort(key=lambda x: x[0]) # Spread overlapping glyphs — a few passes if len(positions) > 1: for _pass in range(5): changed = False 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) changed = True if not changed: break # Build map: body -> true angle (for connector line) true_angles = {p.get("body"): _svg_angle(p.get("absolute_lon", 0.0), asc_lon) for p in planets} for display_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, r_planet, display_angle) g_char = _planet_glyph(body) # Planet glyph (Astronomicon font) dwg.add(dwg.text( g_char, insert=(px, py), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="18px", fill=theme["planet_text"], )) # Retrograde marker — small 'Rx' just after the glyph radially if retro: rx2, ry2 = _polar_to_cartesian(cx, cy, r_planet + 13, display_angle) dwg.add(dwg.text( RETROGRADE, insert=(rx2, ry2), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="9px", fill=theme["planet_text"], )) # Degree label (sans-serif, NOT the glyph font) lx, ly = _polar_to_cartesian(cx, cy, r_deg_lbl, display_angle) dwg.add(dwg.text( _format_degree(deg), insert=(lx, ly), text_anchor="middle", dominant_baseline="central", class_="lbl", font_size="7px", fill=theme["degree_text"], )) # Connector tick: thin line from inner zodiac edge to planet ring, # drawn at the TRUE ecliptic position (not the nudged display pos). true_a = true_angles.get(body, display_angle) tx1, ty1 = _polar_to_cartesian(cx, cy, r_zod_in - 2, true_a) tx2, ty2 = _polar_to_cartesian(cx, cy, r_conn, true_a) dwg.add(dwg.line(start=(tx1, ty1), end=(tx2, ty2), stroke=theme["connector_line"], stroke_width=0.6)) # ── 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 += 22 + n * 15 + 10 if include_houses: h += 22 + 12 * 15 + 10 return h def _render_tables_inline( dwg: svgwrite.Drawing, chart_data: dict, theme: dict, x: float, y: float, max_w: float, include_planets: bool, include_houses: bool, ) -> None: cy = y if include_planets: cy += _render_planet_table(dwg, chart_data, theme, x, cy, max_w) cy += 10 if include_houses: _render_house_table(dwg, chart_data, theme, x, cy, max_w) def _render_planet_table( dwg: svgwrite.Drawing, chart_data: dict, theme: dict, x: float, y: float, max_w: float, ) -> float: planets = chart_data.get("planets", []) row_h = 14 header_h = 18 n = len(planets) 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, h), fill=theme["table_header"])) cols = [("Planet", 52), ("Sign", 44), ("Degree", 54), ("Hse", 30), ("Rx", 18)] 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, 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", ""))[:3] values = [ p["body"].capitalize(), sign_abbr, _format_degree(deg), str(p.get("house", "")), "Rx" 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 + 9), 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: houses = chart_data.get("houses", []) row_h = 14 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, h), fill=theme["table_header"])) cols = [("House", 42), ("Sign", 44), ("Cusp", 54)] 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", ""))[:3] 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 + 9), 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 = 700, table_position: str = "none", format: str = "svg", **kwargs, ) -> dict[str, Any]: """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) canvas_w = size canvas_h = size cx = size / 2 cy = size / 2 outer_r = size / 2 - 4 r_zod_out = _r(outer_r, _R_ZODIAC_OUTER) r_zod_in = _r(outer_r, _R_ZODIAC_INNER) r_transit = outer_r * 0.68 r_natal_out = outer_r * 0.60 r_natal_in = outer_r * 0.50 r_center = _r(outer_r, _R_CENTER) dwg = svgwrite.Drawing(size=(canvas_w, canvas_h)) dwg.defs.add(dwg.style(font_face_css())) dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"])) # Title corner _render_title_corner(dwg, chart_data, theme, "Transit Chart", None) # Zodiac ring r_zod_mid = outer_r * (_R_ZODIAC_GLYPH) for i, sign_name in enumerate(astrology.ZODIAC_SIGNS): mid_a = _svg_angle(i * 30.0 + 15.0, asc_lon) gx, gy = _polar_to_cartesian(cx, cy, r_zod_mid, mid_a) 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"], )) dwg.add(dwg.circle(center=(cx, cy), r=r_zod_out, fill="none", stroke=theme["ring_stroke"], stroke_width=1.5)) dwg.add(dwg.circle(center=(cx, cy), r=r_zod_in, fill="none", stroke=theme["ring_stroke"], stroke_width=1.0)) dwg.add(dwg.circle(center=(cx, cy), r=r_natal_out, fill="none", stroke=theme["ring_stroke"], stroke_width=0.7)) # House cusps for i, house in enumerate(houses): cusp_lon = house.get("absolute_lon", i * 30.0) c = _svg_angle(cusp_lon, asc_lon) is_ang = house.get("house", i + 1) in ANGULAR_HOUSES sx, sy = _polar_to_cartesian(cx, cy, r_natal_in, c) ex, ey = _polar_to_cartesian(cx, cy, r_zod_in, c) 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 = _svg_angle(transit_lons[t_body], asc_lon) a2 = _svg_angle(natal_lons[n_body], asc_lon) x1, y1 = _polar_to_cartesian(cx, cy, r_natal_in - 5, a1) x2, y2 = _polar_to_cartesian(cx, cy, r_natal_in - 5, a2) if color_mode == "bw": dash, width = BW_ASPECT_STYLES.get(asp_name, ("2,3", 0.7)) extra = {"stroke_dasharray": dash} if dash != "none" else {} dwg.add(dwg.line(start=(x1, y1), end=(x2, y2), stroke="#000", stroke_width=width, **extra)) else: color_key, width = COLOR_ASPECT_STYLES.get(asp_name, ("aspect_minor", 0.7)) dwg.add(dwg.line(start=(x1, y1), end=(x2, y2), stroke=theme.get(color_key, "#999"), stroke_width=width, opacity="0.45")) # Transit planets (outer ring) for p in transit: lon = p.get("absolute_lon", 0.0) angle = _svg_angle(lon, asc_lon) px2, py2 = _polar_to_cartesian(cx, cy, r_transit, angle) dwg.add(dwg.text( _planet_glyph(p["body"]), insert=(px2, py2), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="15px", fill=theme["planet_text"], )) # Natal planets (inner ring) for p in natal: lon = p.get("absolute_lon", 0.0) angle = _svg_angle(lon, asc_lon) px2, py2 = _polar_to_cartesian(cx, cy, r_natal_in - 15, angle) retro = p.get("retrograde", False) dwg.add(dwg.text( _planet_glyph(p["body"]), insert=(px2, py2), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="15px", fill=theme["planet_text"], )) if retro: rx2, ry2 = _polar_to_cartesian(cx, cy, r_natal_in - 5, angle) dwg.add(dwg.text(RETROGRADE, insert=(rx2, ry2), text_anchor="middle", dominant_baseline="central", class_="zf", font_size="7px", fill=theme["planet_text"])) # Center circle + angle labels dwg.add(dwg.circle(center=(cx, cy), r=r_center, fill=theme["background"], stroke=theme["ring_stroke"], stroke_width=1.0)) 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 = _svg_angle(lon, asc_lon) ax, ay = _polar_to_cartesian(cx, cy, r_center - 4, a) dwg.add(dwg.text(label, insert=(ax, ay), text_anchor="middle", dominant_baseline="central", class_="lbl", font_size="7px", fill=theme["angle_text"], font_weight="bold")) # Footer inp = chart_data.get("input", {}) house_sys = inp.get("house_system", "placidus").capitalize() dwg.add(dwg.text( f"astro-mcp v{__version__} \u2022 {house_sys} \u2022 Tropical", insert=(5, canvas_h - 4), text_anchor="start", class_="lbl", font_size="7px", fill=theme["footer_text"], )) svg_str = dwg.tostring() return render_envelope(svg_to_image(svg_str, format, size), format, size) # ── 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) -> dict[str, Any]: """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)