chart_renderer.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. """
  2. SVG chart wheel renderer for astro-mcp.
  3. Renders astrological chart wheels using the Astronomicon font.
  4. Supports natal, transit (bi-wheel), synastry (dual wheel), composite, and Davison charts.
  5. Reference: docs/astrological_chart_rendering_guide.md
  6. Coordinate convention
  7. ---------------------
  8. _polar_to_cartesian(cx, cy, r, angle_deg):
  9. angle 0 = 12 o'clock (top)
  10. angle 90 = 3 o'clock (right)
  11. angle 180 = 6 o'clock (bottom)
  12. angle 270 = 9 o'clock (left) <- ASC always here
  13. Rotation formula -> _svg_angle(lon, asc_lon):
  14. svg_angle = (lon - asc_lon + 270) % 360
  15. This maps ASC longitude -> 270° (left) and zodiac increases CCW.
  16. """
  17. from __future__ import annotations
  18. import math
  19. import logging
  20. from typing import Any
  21. import svgwrite
  22. from . import astrology
  23. from . import __version__
  24. from .chart_font import glyph, RETROGRADE
  25. from .chart_styles import (
  26. THEMES,
  27. BW_ASPECT_STYLES,
  28. COLOR_ASPECT_STYLES,
  29. ANGULAR_HOUSES,
  30. font_face_css,
  31. r as _r,
  32. _R_OUTER,
  33. _R_TICK_OUTER,
  34. _R_TICK_INNER_5,
  35. _R_TICK_INNER_1,
  36. _R_ZODIAC_OUTER,
  37. _R_ZODIAC_GLYPH,
  38. _R_ZODIAC_INNER,
  39. _R_HOUSE_NUM,
  40. _R_CUSP_INNER,
  41. _R_PLANET,
  42. _R_PLANET_DEG,
  43. _R_CONNECTOR,
  44. _R_CENTER,
  45. _R_ASPECT,
  46. )
  47. from .chart_helpers import svg_to_image, render_envelope
  48. logger = logging.getLogger("astro-mcp.chart_renderer")
  49. # ── Geometry helpers ──────────────────────────────────────────────────
  50. def _polar_to_cartesian(cx: float, cy: float, r: float, angle_deg: float) -> tuple[float, float]:
  51. """Polar to cartesian.
  52. angle 0=top (12 o'clock), 90=right, 180=bottom, 270=left. Clockwise.
  53. """
  54. rad = math.radians(angle_deg)
  55. return cx + r * math.sin(rad), cy - r * math.cos(rad)
  56. def _svg_angle(lon: float, asc_lon: float) -> float:
  57. """Convert ecliptic longitude to SVG angle given the ASC longitude.
  58. Maps ASC longitude -> 270° (9 o'clock, left).
  59. Zodiac increases counter-clockwise (visually) as angle decreases,
  60. which in our clockwise-angle system means we subtract from the offset.
  61. """
  62. return (asc_lon - lon + 270.0) % 360.0
  63. def _arc_path(cx: float, cy: float, r: float, start_deg: float, end_deg: float, sweep: int = 1) -> str:
  64. """SVG arc path from start_deg to end_deg.
  65. sweep=1: clockwise (in SVG angle space, which is CW visually)
  66. sweep=0: counter-clockwise
  67. Automatically determines large-arc-flag based on sweep direction.
  68. """
  69. start = _polar_to_cartesian(cx, cy, r, start_deg)
  70. end = _polar_to_cartesian(cx, cy, r, end_deg)
  71. if sweep == 1:
  72. diff = (end_deg - start_deg) % 360.0
  73. else:
  74. diff = (start_deg - end_deg) % 360.0
  75. large = 1 if diff > 180 else 0
  76. return (f"M {start[0]:.2f},{start[1]:.2f} "
  77. f"A {r:.2f},{r:.2f} 0 {large},{sweep} {end[0]:.2f},{end[1]:.2f}")
  78. # ── Glyph helpers ─────────────────────────────────────────────────────
  79. def _zodiac_glyph(sign_name: str) -> str:
  80. """Astronomicon character for a zodiac sign."""
  81. name_map = {
  82. "aries": "aries", "taurus": "taurus", "gemini": "gemini",
  83. "cancer": "cancer", "leo": "leo", "virgo": "virgo",
  84. "libra": "libra", "scorpio": "scorpio", "scorpius": "scorpio",
  85. "sagittarius": "sagittarius", "capricorn": "capricorn",
  86. "capricornus": "capricorn", "aquarius": "aquarius", "pisces": "pisces",
  87. }
  88. try:
  89. return glyph(name_map.get(sign_name.lower(), sign_name.lower()))
  90. except KeyError:
  91. return "?"
  92. def _planet_glyph(body_name: str) -> str:
  93. """Astronomicon character for a planet/point."""
  94. try:
  95. return glyph(body_name.lower().strip())
  96. except KeyError:
  97. return "?"
  98. def _format_degree(deg_float: float) -> str:
  99. """Format 14.3698 as 14°22'"""
  100. d = int(deg_float)
  101. m = int(round((deg_float - d) * 60))
  102. if m >= 60:
  103. d += 1
  104. m = 0
  105. return f"{d}\u00b0{m:02d}\u2019"
  106. def _to_roman(num: int) -> str:
  107. """Convert an integer (1-12) to a Roman numeral string."""
  108. roman_map = {
  109. 1: "I", 2: "II", 3: "III", 4: "IV", 5: "V",
  110. 6: "VI", 7: "VII", 8: "VIII", 9: "IX", 10: "X",
  111. 11: "XI", 12: "XII",
  112. }
  113. return roman_map.get(num, str(num))
  114. # ── Main natal wheel renderer ─────────────────────────────────────────
  115. def render_natal_wheel(
  116. chart_data: dict[str, Any],
  117. style: str = "modern",
  118. color_mode: str = "color",
  119. size: int = 700,
  120. table_position: str = "none",
  121. include_planets: bool = False,
  122. include_houses: bool = False,
  123. title: str | None = None,
  124. subtitle: str | None = None,
  125. format: str = "svg",
  126. ) -> dict[str, Any]:
  127. """Render a natal chart wheel as SVG (or raster image).
  128. The wheel fills a square canvas. ASC is always at 9 o'clock (left).
  129. MC is placed at its actual ecliptic longitude — it is NOT forced to 12 o'clock
  130. (that would only be true in Equal House).
  131. Args:
  132. chart_data: Output from calculate_natal_chart.
  133. style: "modern" | "minimal"
  134. color_mode: "color" | "bw" | "dark"
  135. size: Canvas side length in pixels (square).
  136. table_position: "none", "below", or "right".
  137. include_planets: Include planet data table.
  138. include_houses: Include house cusp table.
  139. title: Override auto-generated title line.
  140. subtitle: Override auto-generated subtitle line.
  141. format: Output format — "svg", "png", or "jpg". Default "svg".
  142. Returns:
  143. Dict with content, format, content_type, width, height, and included.
  144. """
  145. theme = THEMES.get(color_mode, THEMES["color"])
  146. planets = chart_data.get("planets", [])
  147. houses = chart_data.get("houses", [])
  148. aspects = chart_data.get("aspects", [])
  149. angles = chart_data.get("angles", {})
  150. chart_type = chart_data.get("chart_type", "natal")
  151. inp = chart_data.get("input", {})
  152. # ── Canvas & wheel geometry ──────────────────────────────────────
  153. has_tables = table_position in ("below", "right") and (include_planets or include_houses)
  154. if table_position == "right" and has_tables:
  155. table_w = 230
  156. canvas_w = size + table_w
  157. canvas_h = size
  158. elif table_position == "below" and has_tables:
  159. table_h = _estimate_table_height(chart_data, include_planets, include_houses)
  160. canvas_w = size
  161. canvas_h = size + table_h + 8
  162. else:
  163. canvas_w = size
  164. canvas_h = size
  165. # Wheel is always perfectly square, centered in the first 'size x size' area
  166. wheel_cx = size / 2
  167. wheel_cy = size / 2
  168. outer_r = size / 2 - 20 # margin for angle labels and axis lines
  169. # ── ASC longitude for rotation ───────────────────────────────────
  170. asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
  171. # ── SVG setup ────────────────────────────────────────────────────
  172. dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
  173. dwg.set_desc(title="Astro-MCP Chart Wheel")
  174. dwg.defs.add(dwg.style(font_face_css()))
  175. # Background
  176. dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
  177. # ── Zodiac ring ──────────────────────────────────────────────────
  178. r_zod_out = _r(outer_r, _R_ZODIAC_OUTER)
  179. r_zod_in = _r(outer_r, _R_ZODIAC_INNER)
  180. r_zod_mid = _r(outer_r, _R_ZODIAC_GLYPH)
  181. for i, sign_name in enumerate(astrology.ZODIAC_SIGNS):
  182. seg_start_lon = i * 30.0
  183. seg_end_lon = (i + 1) * 30.0
  184. a_start = _svg_angle(seg_start_lon, asc_lon)
  185. a_end = _svg_angle(seg_end_lon, asc_lon)
  186. # Segment fill (element colour in colour mode, alternating grey in bw)
  187. if style != "minimal":
  188. if color_mode == "bw":
  189. seg_color = "#e8e8e8" if i % 2 == 0 else "#f8f8f8"
  190. else:
  191. element = astrology.SIGN_ELEMENTS.get(sign_name, "")
  192. seg_color = theme.get(f"zodiac_{element}", theme["ring_fill"])
  193. # Zodiac band segment fill (between outer and inner zodiac edges)
  194. # Path: outer arc (CCW) → radial line → inner arc (CW) → radial line → close
  195. sx_out, sy_out = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_start)
  196. ex_out, ey_out = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_end)
  197. sx_in, sy_in = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a_start)
  198. ex_in, ey_in = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a_end)
  199. path_d = (
  200. f"M {sx_out:.2f},{sy_out:.2f} "
  201. f"A {r_zod_out:.2f},{r_zod_out:.2f} 0 0,0 {ex_out:.2f},{ey_out:.2f} "
  202. f"L {ex_in:.2f},{ey_in:.2f} "
  203. f"A {r_zod_in:.2f},{r_zod_in:.2f} 0 0,1 {sx_in:.2f},{sy_in:.2f} Z"
  204. )
  205. dwg.add(dwg.path(d=path_d, fill=seg_color, stroke="none"))
  206. # Sign glyph at midpoint of segment
  207. mid_a = _svg_angle(seg_start_lon + 15.0, asc_lon)
  208. gx, gy = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_mid, mid_a)
  209. dwg.add(dwg.text(
  210. _zodiac_glyph(sign_name), insert=(gx, gy),
  211. text_anchor="middle", dominant_baseline="central",
  212. class_="zf", font_size="26px", fill=theme["sign_text"],
  213. ))
  214. # Zodiac band border circles
  215. dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=r_zod_out, fill="none",
  216. stroke=theme["ring_stroke"], stroke_width=1.5))
  217. dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=r_zod_in, fill="none",
  218. stroke=theme["ring_stroke"], stroke_width=1.0))
  219. # Zodiac sign boundary lines (full length from outer to inner edge)
  220. for i in range(12):
  221. a = _svg_angle(i * 30.0, asc_lon)
  222. sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a)
  223. ex, ey = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a)
  224. dwg.add(dwg.line(start=(sx, sy), end=(ex, ey),
  225. stroke=theme["ring_stroke"], stroke_width=0.8))
  226. # ── Degree tick marks on outer zodiac ring ───────────────────────
  227. r_tick_out = _r(outer_r, _R_TICK_OUTER)
  228. for deg_tick in range(0, 360):
  229. t_angle = _svg_angle(float(deg_tick), asc_lon)
  230. is_sign = deg_tick % 30 == 0
  231. is_10 = deg_tick % 10 == 0
  232. is_5 = deg_tick % 5 == 0
  233. if is_sign or is_10:
  234. tick_len = 10
  235. stroke = theme["tick_major"]
  236. width = 1.5
  237. elif is_5:
  238. tick_len = 7
  239. stroke = theme["tick_major"]
  240. width = 1.0
  241. else:
  242. tick_len = 3
  243. stroke = theme["tick_minor"]
  244. width = 0.8
  245. # Ticks extend OUTWARD from the zodiac outer edge
  246. t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, t_angle)
  247. t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out + tick_len, t_angle)
  248. dwg.add(dwg.line(start=(t1x, t1y), end=(t2x, t2y),
  249. stroke=stroke, stroke_width=width))
  250. # ── House sectors ────────────────────────────────────────────────
  251. r_cusp_in = _r(outer_r, _R_CUSP_INNER)
  252. r_hnum = _r(outer_r, _R_HOUSE_NUM)
  253. for i, house in enumerate(houses):
  254. cusp_lon = house.get("absolute_lon", i * 30.0)
  255. next_cusp = houses[(i + 1) % 12].get("absolute_lon", ((i + 1) % 12) * 30.0)
  256. c1 = _svg_angle(cusp_lon, asc_lon)
  257. c2 = _svg_angle(next_cusp, asc_lon)
  258. # Cusp line from inner zodiac edge inward
  259. is_angular = house.get("house", i + 1) in ANGULAR_HOUSES
  260. sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, r_cusp_in, c1)
  261. ex, ey = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, c1)
  262. dwg.add(dwg.line(
  263. start=(sx, sy), end=(ex, ey),
  264. stroke=theme["house_line"],
  265. stroke_width=2.0 if is_angular else 0.8,
  266. ))
  267. # House number label — midpoint of sector going CCW (decreasing angle)
  268. diff = (c1 - c2) % 360.0 # CCW distance from c1 to c2
  269. mid_c = (c1 - diff / 2.0) % 360.0
  270. hx, hy = _polar_to_cartesian(wheel_cx, wheel_cy, r_hnum, mid_c)
  271. house_num = house.get("house", i + 1)
  272. dwg.add(dwg.text(
  273. _to_roman(house_num), insert=(hx, hy),
  274. text_anchor="middle", dominant_baseline="central",
  275. class_="lbl", font_size="10px", font_style="italic",
  276. fill=theme["degree_text"],
  277. ))
  278. # ── Angle axis lines (ASC-DSC and MC-IC) ─────────────────────────
  279. # Extend from center circle through zodiac band and beyond outer rim
  280. for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
  281. lon = angles.get(key, {}).get("absolute_lon")
  282. if lon is not None:
  283. a = _svg_angle(lon, asc_lon)
  284. ax1, ay1 = _polar_to_cartesian(wheel_cx, wheel_cy, _r(outer_r, _R_CENTER), a)
  285. ax2, ay2 = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out + 24, a)
  286. dwg.add(dwg.line(
  287. start=(ax1, ay1), end=(ax2, ay2),
  288. stroke=theme["axis_line"], stroke_width=1.5,
  289. ))
  290. # ── Center circle ─────────────────────────────────────────────────
  291. r_center = _r(outer_r, _R_CENTER)
  292. dwg.add(dwg.circle(center=(wheel_cx, wheel_cy), r=r_center,
  293. fill=theme["background"],
  294. stroke=theme["ring_stroke"], stroke_width=1.0))
  295. # ── Aspect lines ─────────────────────────────────────────────────
  296. planet_lons = {p["body"]: p["absolute_lon"] for p in planets}
  297. r_asp = _r(outer_r, _R_ASPECT)
  298. for asp in aspects:
  299. b1 = asp.get("body1", "")
  300. b2 = asp.get("body2", "")
  301. asp_name = asp.get("aspect", "")
  302. if b1 not in planet_lons or b2 not in planet_lons:
  303. continue
  304. a1 = _svg_angle(planet_lons[b1], asc_lon)
  305. a2 = _svg_angle(planet_lons[b2], asc_lon)
  306. x1, y1 = _polar_to_cartesian(wheel_cx, wheel_cy, r_asp, a1)
  307. x2, y2 = _polar_to_cartesian(wheel_cx, wheel_cy, r_asp, a2)
  308. if color_mode == "bw":
  309. dash, width = BW_ASPECT_STYLES.get(asp_name, ("2,3", 0.7))
  310. extra = {"stroke_dasharray": dash} if dash != "none" else {}
  311. dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
  312. stroke="#000", stroke_width=width, **extra))
  313. else:
  314. color_key, width = COLOR_ASPECT_STYLES.get(asp_name, ("aspect_minor", 0.7))
  315. dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
  316. stroke=theme.get(color_key, "#999"),
  317. stroke_width=width, opacity="0.55"))
  318. # ── Planet glyphs with collision avoidance and connector ticks ───
  319. _render_planets(dwg, planets, wheel_cx, wheel_cy, outer_r, asc_lon, theme, color_mode)
  320. # ── Planet position ticks on inner zodiac edge ────────────────────
  321. for p in planets:
  322. lon = p.get("absolute_lon", 0.0)
  323. a = _svg_angle(lon, asc_lon)
  324. t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in - 3, a)
  325. t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in + 3, a)
  326. dwg.add(dwg.line(start=(t1x, t1y), end=(t2x, t2y),
  327. stroke=theme["ring_stroke"], stroke_width=1.5))
  328. # ── Angle glyphs at the end of extended axis lines ────────────────
  329. angle_chars = {
  330. "ascendant": glyph("ascendant"),
  331. "descendant": glyph("descendant"),
  332. "midheaven": glyph("midheaven"),
  333. "imum_coeli": glyph("imum_coeli"),
  334. }
  335. r_angle_lbl = r_zod_out + 22
  336. for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
  337. lon = angles.get(key, {}).get("absolute_lon")
  338. if lon is None:
  339. continue
  340. a = _svg_angle(lon, asc_lon)
  341. nx, ny = _polar_to_cartesian(wheel_cx, wheel_cy, r_angle_lbl, a)
  342. g_char = angle_chars.get(key, "")
  343. # Offset glyph tangentially to avoid overlapping with the axis line
  344. # ASC up, DSC down, MC right, IC left
  345. offset = 8
  346. rad = math.radians(a)
  347. # Tangential direction: rotate 90° CW from radial
  348. tx = math.cos(rad) * offset
  349. ty = math.sin(rad) * offset
  350. nx += tx
  351. ny += ty
  352. dwg.add(dwg.text(
  353. g_char, insert=(nx, ny),
  354. text_anchor="middle", dominant_baseline="central",
  355. class_="zf", font_size="22px", fill=theme["angle_text"],
  356. ))
  357. # ── Title block — top-left corner ────────────────────────────────
  358. _render_title_corner(dwg, chart_data, theme, title, subtitle)
  359. # ── Footer — bottom-left corner ───────────────────────────────────
  360. footer_y = canvas_h - 4
  361. house_sys = inp.get("house_system", "placidus").capitalize()
  362. dwg.add(dwg.text(
  363. f"astro-mcp v{__version__} \u2022 {house_sys} \u2022 Tropical",
  364. insert=(5, footer_y),
  365. text_anchor="start", dominant_baseline="auto",
  366. class_="lbl", font_size="7px", fill=theme["footer_text"],
  367. ))
  368. # ── Tables ───────────────────────────────────────────────────────
  369. if has_tables:
  370. if table_position == "right":
  371. table_x = size + 8
  372. table_y = 8
  373. _render_tables_inline(dwg, chart_data, theme, table_x, table_y, canvas_h - 16,
  374. include_planets, include_houses)
  375. elif table_position == "below":
  376. table_x = 8
  377. table_y = size + 8
  378. _render_tables_inline(dwg, chart_data, theme, table_x, table_y, size - 16,
  379. include_planets, include_houses)
  380. svg_str = dwg.tostring()
  381. return render_envelope(svg_to_image(svg_str, format, size), format, size)
  382. # ── Title block — corner ──────────────────────────────────────────────
  383. def _render_title_corner(
  384. dwg: svgwrite.Drawing,
  385. chart_data: dict,
  386. theme: dict,
  387. title: str | None,
  388. subtitle: str | None,
  389. ) -> None:
  390. """Render title block in the top-left corner using a grouped layout."""
  391. inp = chart_data.get("input", {})
  392. angles = chart_data.get("angles", {})
  393. if title is None:
  394. chart_type = chart_data.get("chart_type", "natal").capitalize()
  395. name = inp.get("name", "")
  396. title = f"{name}" if name else f"{chart_type} Chart"
  397. # Build title lines as (text, font_size, fill, bold) tuples
  398. lines: list[tuple[str, str, str, bool]] = []
  399. # Small "Natal Chart for:" label
  400. if chart_data.get("chart_type", "natal") == "natal" and title:
  401. lines.append(("Natal Chart for:", "8px", theme["footer_text"], False))
  402. # Name (bold, larger)
  403. lines.append((title, "14px", theme["title_text"], True))
  404. # Birth datetime
  405. bdt = inp.get("birth_datetime", "")
  406. if bdt:
  407. try:
  408. from datetime import datetime, timezone
  409. dt = datetime.fromisoformat(bdt.replace("Z", "+00:00"))
  410. dt_str = dt.strftime("%d %B %Y %H:%M")
  411. if dt.tzinfo and dt.tzinfo != timezone.utc:
  412. tz_name = dt.strftime("%Z")
  413. if tz_name and tz_name != "UTC":
  414. dt_str += f" {tz_name}"
  415. else:
  416. # Naive datetime (local time from DB) — use the timezone field
  417. tz_name = inp.get("timezone", "")
  418. if tz_name:
  419. dt_str += f" {tz_name}"
  420. lines.append((dt_str, "8px", theme["data_text"], False))
  421. except Exception:
  422. pass
  423. # Birthplace
  424. bp = inp.get("birthplace", "")
  425. if bp:
  426. lines.append((bp, "8px", theme["data_text"], False))
  427. # Lat/Lon
  428. lat = inp.get("latitude")
  429. lon = inp.get("longitude")
  430. if lat is not None and lon is not None:
  431. lat_dir = "N" if lat >= 0 else "S"
  432. lon_dir = "E" if lon >= 0 else "W"
  433. lines.append((f"{abs(lat):.4f}\u00b0{lat_dir} {abs(lon):.4f}\u00b0{lon_dir}", "7.5px", theme["data_text"], False))
  434. if subtitle:
  435. lines.append((subtitle, "7.5px", theme["data_text"], False))
  436. # Render all lines inside a <g> group with consistent line spacing
  437. x = 8
  438. y_start = 12
  439. line_gap = 4 # extra gap between lines in px
  440. # Calculate positions: each line's y = previous y + previous font_size + line_gap
  441. g = dwg.g(class_="title-block")
  442. y = y_start
  443. for i, (text, font_size, fill, bold) in enumerate(lines):
  444. # Parse font_size to float for line height calculation
  445. fs = float(font_size.replace("px", ""))
  446. # Use hanging baseline so y is the top of the text
  447. t = dwg.text(
  448. text, insert=(x, y),
  449. text_anchor="start", dominant_baseline="hanging",
  450. class_="lbl", font_size=font_size, fill=fill,
  451. )
  452. if bold:
  453. t.attribs["font-weight"] = "bold"
  454. g.add(t)
  455. y += fs + line_gap
  456. dwg.add(g)
  457. # ── Planet rendering with collision avoidance ─────────────────────────
  458. def _render_planets(
  459. dwg: svgwrite.Drawing,
  460. planets: list[dict],
  461. cx: float, cy: float,
  462. outer_r: float,
  463. asc_lon: float,
  464. theme: dict,
  465. color_mode: str,
  466. ) -> None:
  467. """Render planet glyphs, degree labels, and connector tick lines."""
  468. MIN_SPACING = 6.0 # minimum degrees between glyph centres
  469. r_planet = _r(outer_r, _R_PLANET)
  470. r_deg_lbl = _r(outer_r, _R_PLANET_DEG)
  471. r_conn = _r(outer_r, _R_CONNECTOR) # connector tick inner end
  472. r_zod_in = _r(outer_r, _R_ZODIAC_INNER)
  473. # Calculate display angles (may be nudged for spacing)
  474. positions: list[tuple[float, dict]] = []
  475. for p in planets:
  476. lon = p.get("absolute_lon", 0.0)
  477. angle = _svg_angle(lon, asc_lon)
  478. positions.append((angle, p))
  479. positions.sort(key=lambda x: x[0])
  480. # Spread overlapping glyphs — a few passes
  481. if len(positions) > 1:
  482. for _pass in range(5):
  483. changed = False
  484. for i in range(len(positions)):
  485. angle_i, p_i = positions[i]
  486. angle_next, p_next = positions[(i + 1) % len(positions)]
  487. diff = (angle_next - angle_i) % 360.0
  488. if 0 < diff < MIN_SPACING:
  489. shift = (MIN_SPACING - diff) / 2.0
  490. positions[i] = ((angle_i - shift) % 360.0, p_i)
  491. positions[(i+1) % len(positions)] = ((angle_next + shift) % 360.0, p_next)
  492. changed = True
  493. if not changed:
  494. break
  495. # Build map: body -> true angle (for connector line)
  496. true_angles = {p.get("body"): _svg_angle(p.get("absolute_lon", 0.0), asc_lon)
  497. for p in planets}
  498. for display_angle, p in positions:
  499. body = p["body"]
  500. retro = p.get("retrograde", False)
  501. deg = p.get("degree_within_sign", 0.0)
  502. px, py = _polar_to_cartesian(cx, cy, r_planet, display_angle)
  503. g_char = _planet_glyph(body)
  504. # Planet glyph (Astronomicon font)
  505. dwg.add(dwg.text(
  506. g_char, insert=(px, py),
  507. text_anchor="middle", dominant_baseline="central",
  508. class_="zf", font_size="18px", fill=theme["planet_text"],
  509. ))
  510. # Retrograde marker — small 'Rx' just after the glyph radially
  511. if retro:
  512. rx2, ry2 = _polar_to_cartesian(cx, cy, r_planet + 13, display_angle)
  513. dwg.add(dwg.text(
  514. RETROGRADE, insert=(rx2, ry2),
  515. text_anchor="middle", dominant_baseline="central",
  516. class_="zf", font_size="9px", fill=theme["planet_text"],
  517. ))
  518. # Degree label (sans-serif, NOT the glyph font)
  519. lx, ly = _polar_to_cartesian(cx, cy, r_deg_lbl, display_angle)
  520. dwg.add(dwg.text(
  521. _format_degree(deg), insert=(lx, ly),
  522. text_anchor="middle", dominant_baseline="central",
  523. class_="lbl", font_size="7px", fill=theme["degree_text"],
  524. ))
  525. # Connector tick: thin line from inner zodiac edge to planet ring,
  526. # drawn at the TRUE ecliptic position (not the nudged display pos).
  527. true_a = true_angles.get(body, display_angle)
  528. tx1, ty1 = _polar_to_cartesian(cx, cy, r_zod_in - 2, true_a)
  529. tx2, ty2 = _polar_to_cartesian(cx, cy, r_conn, true_a)
  530. dwg.add(dwg.line(start=(tx1, ty1), end=(tx2, ty2),
  531. stroke=theme["connector_line"],
  532. stroke_width=0.6))
  533. # ── Tables ────────────────────────────────────────────────────────────
  534. def _estimate_table_height(chart_data: dict, include_planets: bool, include_houses: bool) -> int:
  535. h = 0
  536. if include_planets:
  537. n = len(chart_data.get("planets", []))
  538. h += 22 + n * 15 + 10
  539. if include_houses:
  540. h += 22 + 12 * 15 + 10
  541. return h
  542. def _render_tables_inline(
  543. dwg: svgwrite.Drawing,
  544. chart_data: dict,
  545. theme: dict,
  546. x: float, y: float,
  547. max_w: float,
  548. include_planets: bool,
  549. include_houses: bool,
  550. ) -> None:
  551. cy = y
  552. if include_planets:
  553. cy += _render_planet_table(dwg, chart_data, theme, x, cy, max_w)
  554. cy += 10
  555. if include_houses:
  556. _render_house_table(dwg, chart_data, theme, x, cy, max_w)
  557. def _render_planet_table(
  558. dwg: svgwrite.Drawing, chart_data: dict, theme: dict,
  559. x: float, y: float, max_w: float,
  560. ) -> float:
  561. planets = chart_data.get("planets", [])
  562. row_h = 14
  563. header_h = 18
  564. n = len(planets)
  565. h = header_h + n * row_h + 4
  566. dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
  567. stroke=theme["table_line"], stroke_width=0.5))
  568. dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill=theme["table_header"]))
  569. cols = [("Planet", 52), ("Sign", 44), ("Degree", 54), ("Hse", 30), ("Rx", 18)]
  570. cx_pos = x + 5
  571. for hdr, cw in cols:
  572. dwg.add(dwg.text(hdr, insert=(cx_pos, y + 12),
  573. class_="lbl", font_size="8px", fill="#fff", font_weight="bold"))
  574. cx_pos += cw
  575. for j, p in enumerate(planets):
  576. ry = y + header_h + j * row_h
  577. if j % 2 == 1:
  578. dwg.add(dwg.rect(insert=(x, ry), size=(max_w, row_h), fill=theme["table_row_alt"]))
  579. deg = p.get("degree_within_sign", 0)
  580. sign_abbr = p.get("sign_abbreviation", p.get("sign", ""))[:3]
  581. values = [
  582. p["body"].capitalize(),
  583. sign_abbr,
  584. _format_degree(deg),
  585. str(p.get("house", "")),
  586. "Rx" if p.get("retrograde") else "",
  587. ]
  588. cx_pos = x + 5
  589. for val, (_, cw) in zip(values, cols):
  590. dwg.add(dwg.text(val, insert=(cx_pos, ry + 9),
  591. class_="lbl", font_size="8px", fill=theme["table_text"]))
  592. cx_pos += cw
  593. return h
  594. def _render_house_table(
  595. dwg: svgwrite.Drawing, chart_data: dict, theme: dict,
  596. x: float, y: float, max_w: float,
  597. ) -> float:
  598. houses = chart_data.get("houses", [])
  599. row_h = 14
  600. header_h = 18
  601. n = len(houses)
  602. h = header_h + n * row_h + 4
  603. dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill="none",
  604. stroke=theme["table_line"], stroke_width=0.5))
  605. dwg.add(dwg.rect(insert=(x, y), size=(max_w, h), fill=theme["table_header"]))
  606. cols = [("House", 42), ("Sign", 44), ("Cusp", 54)]
  607. cx_pos = x + 5
  608. for hdr, cw in cols:
  609. dwg.add(dwg.text(hdr, insert=(cx_pos, y + 12),
  610. class_="lbl", font_size="8px", fill="#fff", font_weight="bold"))
  611. cx_pos += cw
  612. for j, hd in enumerate(houses):
  613. ry = y + header_h + j * row_h
  614. if j % 2 == 1:
  615. dwg.add(dwg.rect(insert=(x, ry), size=(max_w, row_h), fill=theme["table_row_alt"]))
  616. cusp_deg = hd.get("degree", 0)
  617. sign_abbr = hd.get("abbreviation", hd.get("sign", ""))[:3]
  618. values = [str(hd.get("house", j + 1)), sign_abbr, _format_degree(cusp_deg)]
  619. cx_pos = x + 5
  620. for val, (_, cw) in zip(values, cols):
  621. dwg.add(dwg.text(val, insert=(cx_pos, ry + 9),
  622. class_="lbl", font_size="8px", fill=theme["table_text"]))
  623. cx_pos += cw
  624. return h
  625. # ── Transit bi-wheel renderer ─────────────────────────────────────────
  626. def render_transit_wheel(
  627. chart_data: dict[str, Any],
  628. style: str = "modern",
  629. color_mode: str = "color",
  630. size: int = 700,
  631. table_position: str = "none",
  632. format: str = "svg",
  633. **kwargs,
  634. ) -> dict[str, Any]:
  635. """Render a transit chart as bi-wheel (natal inner, transit outer)."""
  636. theme = THEMES.get(color_mode, THEMES["color"])
  637. natal = chart_data.get("natal_planets", [])
  638. transit = chart_data.get("transiting_planets", [])
  639. houses = chart_data.get("houses", [])
  640. aspects = chart_data.get("aspects", [])
  641. angles = chart_data.get("angles", {})
  642. asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
  643. canvas_w = size
  644. canvas_h = size
  645. cx = size / 2
  646. cy = size / 2
  647. outer_r = size / 2 - 4
  648. r_zod_out = _r(outer_r, _R_ZODIAC_OUTER)
  649. r_zod_in = _r(outer_r, _R_ZODIAC_INNER)
  650. r_transit = outer_r * 0.68
  651. r_natal_out = outer_r * 0.60
  652. r_natal_in = outer_r * 0.50
  653. r_center = _r(outer_r, _R_CENTER)
  654. dwg = svgwrite.Drawing(size=(canvas_w, canvas_h))
  655. dwg.defs.add(dwg.style(font_face_css()))
  656. dwg.add(dwg.rect(insert=(0, 0), size=(canvas_w, canvas_h), fill=theme["background"]))
  657. # Title corner
  658. _render_title_corner(dwg, chart_data, theme, "Transit Chart", None)
  659. # Zodiac ring
  660. r_zod_mid = outer_r * (_R_ZODIAC_GLYPH)
  661. for i, sign_name in enumerate(astrology.ZODIAC_SIGNS):
  662. mid_a = _svg_angle(i * 30.0 + 15.0, asc_lon)
  663. gx, gy = _polar_to_cartesian(cx, cy, r_zod_mid, mid_a)
  664. dwg.add(dwg.text(
  665. _zodiac_glyph(sign_name), insert=(gx, gy),
  666. text_anchor="middle", dominant_baseline="central",
  667. class_="zf", font_size="18px", fill=theme["sign_text"],
  668. ))
  669. dwg.add(dwg.circle(center=(cx, cy), r=r_zod_out, fill="none",
  670. stroke=theme["ring_stroke"], stroke_width=1.5))
  671. dwg.add(dwg.circle(center=(cx, cy), r=r_zod_in, fill="none",
  672. stroke=theme["ring_stroke"], stroke_width=1.0))
  673. dwg.add(dwg.circle(center=(cx, cy), r=r_natal_out, fill="none",
  674. stroke=theme["ring_stroke"], stroke_width=0.7))
  675. # House cusps
  676. for i, house in enumerate(houses):
  677. cusp_lon = house.get("absolute_lon", i * 30.0)
  678. c = _svg_angle(cusp_lon, asc_lon)
  679. is_ang = house.get("house", i + 1) in ANGULAR_HOUSES
  680. sx, sy = _polar_to_cartesian(cx, cy, r_natal_in, c)
  681. ex, ey = _polar_to_cartesian(cx, cy, r_zod_in, c)
  682. dwg.add(dwg.line(start=(sx, sy), end=(ex, ey),
  683. stroke=theme["house_line"], stroke_width=2.0 if is_ang else 0.8))
  684. # Transit-to-natal aspect lines
  685. natal_lons = {p["body"]: p["absolute_lon"] for p in natal}
  686. transit_lons = {p["body"]: p["absolute_lon"] for p in transit}
  687. for asp in aspects:
  688. t_body = asp.get("transiting", "")
  689. n_body = asp.get("natal", "")
  690. asp_name = asp.get("aspect", "")
  691. if t_body not in transit_lons or n_body not in natal_lons:
  692. continue
  693. a1 = _svg_angle(transit_lons[t_body], asc_lon)
  694. a2 = _svg_angle(natal_lons[n_body], asc_lon)
  695. x1, y1 = _polar_to_cartesian(cx, cy, r_natal_in - 5, a1)
  696. x2, y2 = _polar_to_cartesian(cx, cy, r_natal_in - 5, a2)
  697. if color_mode == "bw":
  698. dash, width = BW_ASPECT_STYLES.get(asp_name, ("2,3", 0.7))
  699. extra = {"stroke_dasharray": dash} if dash != "none" else {}
  700. dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
  701. stroke="#000", stroke_width=width, **extra))
  702. else:
  703. color_key, width = COLOR_ASPECT_STYLES.get(asp_name, ("aspect_minor", 0.7))
  704. dwg.add(dwg.line(start=(x1, y1), end=(x2, y2),
  705. stroke=theme.get(color_key, "#999"),
  706. stroke_width=width, opacity="0.45"))
  707. # Transit planets (outer ring)
  708. for p in transit:
  709. lon = p.get("absolute_lon", 0.0)
  710. angle = _svg_angle(lon, asc_lon)
  711. px2, py2 = _polar_to_cartesian(cx, cy, r_transit, angle)
  712. dwg.add(dwg.text(
  713. _planet_glyph(p["body"]), insert=(px2, py2),
  714. text_anchor="middle", dominant_baseline="central",
  715. class_="zf", font_size="15px", fill=theme["planet_text"],
  716. ))
  717. # Natal planets (inner ring)
  718. for p in natal:
  719. lon = p.get("absolute_lon", 0.0)
  720. angle = _svg_angle(lon, asc_lon)
  721. px2, py2 = _polar_to_cartesian(cx, cy, r_natal_in - 15, angle)
  722. retro = p.get("retrograde", False)
  723. dwg.add(dwg.text(
  724. _planet_glyph(p["body"]), insert=(px2, py2),
  725. text_anchor="middle", dominant_baseline="central",
  726. class_="zf", font_size="15px", fill=theme["planet_text"],
  727. ))
  728. if retro:
  729. rx2, ry2 = _polar_to_cartesian(cx, cy, r_natal_in - 5, angle)
  730. dwg.add(dwg.text(RETROGRADE, insert=(rx2, ry2),
  731. text_anchor="middle", dominant_baseline="central",
  732. class_="zf", font_size="7px", fill=theme["planet_text"]))
  733. # Center circle + angle labels
  734. dwg.add(dwg.circle(center=(cx, cy), r=r_center, fill=theme["background"],
  735. stroke=theme["ring_stroke"], stroke_width=1.0))
  736. angle_labels = {"ascendant": "ASC", "descendant": "DSC",
  737. "midheaven": "MC", "imum_coeli": "IC"}
  738. for key, label in angle_labels.items():
  739. lon = angles.get(key, {}).get("absolute_lon")
  740. if lon is not None:
  741. a = _svg_angle(lon, asc_lon)
  742. ax, ay = _polar_to_cartesian(cx, cy, r_center - 4, a)
  743. dwg.add(dwg.text(label, insert=(ax, ay),
  744. text_anchor="middle", dominant_baseline="central",
  745. class_="lbl", font_size="7px", fill=theme["angle_text"],
  746. font_weight="bold"))
  747. # Footer
  748. inp = chart_data.get("input", {})
  749. house_sys = inp.get("house_system", "placidus").capitalize()
  750. dwg.add(dwg.text(
  751. f"astro-mcp v{__version__} \u2022 {house_sys} \u2022 Tropical",
  752. insert=(5, canvas_h - 4),
  753. text_anchor="start", class_="lbl", font_size="7px", fill=theme["footer_text"],
  754. ))
  755. svg_str = dwg.tostring()
  756. return render_envelope(svg_to_image(svg_str, format, size), format, size)
  757. # ── Synastry renderer (stub) ──────────────────────────────────────────
  758. def render_synastry_wheel(chart_data, **kwargs):
  759. """Render synastry chart. TODO: full dual-wheel implementation."""
  760. return render_natal_wheel(chart_data, **kwargs)
  761. # ── Dispatch ──────────────────────────────────────────────────────────
  762. RENDERERS = {
  763. "natal": render_natal_wheel,
  764. "transit": render_transit_wheel,
  765. "synastry": render_synastry_wheel,
  766. "composite": render_natal_wheel,
  767. "davison": render_natal_wheel,
  768. }
  769. def render(chart_data: dict[str, Any], **kwargs) -> dict[str, Any]:
  770. """Render a chart wheel. Auto-detects chart type from chart_data."""
  771. chart_type = chart_data.get("chart_type", "natal")
  772. renderer = RENDERERS.get(chart_type, render_natal_wheel)
  773. return renderer(chart_data, **kwargs)