| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909 |
- """
- 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 <g> 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)
|