Kaynağa Gözat

feat(chart): SVG chart wheel renderer with Astronomicon font

Chart rendering engine for astro-mcp. Renders professional astrological
chart wheels as SVG with the Astronomicon font (OFL licensed).

Renderer (chart_renderer.py):
- Natal wheel with zodiac ring, house sectors, planet glyphs, aspect lines
- Transit bi-wheel (natal inner + transit outer)
- Three color modes: color (element-themed), bw (line-style coded), dark (web)
- Zodiac band rotates based on ASC (correct for all house systems)
- Degree tick marks on zodiac ring (every 5°, major at 30°)
- Angular house cusps (1,4,7,10) drawn bolder
- Planet collision avoidance (spreads overlapping glyphs)
- Angle axis lines (ASC-DSC horizontal, MC-IC vertical)
- Angle labels at outer edge with degree+sign for ASC
- Compact title block: chart type, birth datetime, lat/lon, house system
- Footer: astro-mcp version, house system, zodiac type
- Table layouts: 'none' (wheel only), 'below' (portrait), 'right' (landscape)
- Planet table: glyph, sign, degree, house, Rx
- House table: house, sign, cusp degree

MCP Tools (10 new render tools):
- render_natal_chart / render_natal_chart_by_id
- render_transit_chart / render_transit_chart_by_id
- render_synastry_chart / render_synastry_chart_by_id
- render_composite_chart / render_composite_chart_by_id
- render_davison_chart / render_davison_chart_by_id

Each render tool mirrors the corresponding calc tool's birth data params,
plus rendering options (style, color_mode, size, table_position, etc).

Reference docs:
- docs/astrological_chart_rendering_guide.md (comprehensive guide)
- docs/chart-rendering-api-design.md (API design decisions)
Lukas Goldschmidt 1 ay önce
ebeveyn
işleme
13cc83329b

+ 574 - 0
docs/astrological_chart_rendering_guide.md

@@ -0,0 +1,574 @@
+# Astrological Chart Rendering — Complete Technical Guide
+
+A reference for developers building a natal (birth) chart renderer. Covers geometry, coordinate systems, layers, data tables, glyphs, and the differences between traditional and modern layouts.
+
+---
+
+## 1. What a Chart Is
+
+A natal (birth) chart is a circular map of the sky at the exact moment and place of someone's birth. It answers: *where was every planet, from the viewpoint of Earth, at that instant?* The chart encodes that snapshot into a standardised circular diagram — called a **chart wheel** or **horoscope wheel** — that can be read at a glance.
+
+A complete chart has four interlocking layers:
+
+1. **The Zodiac band** — the 12 signs, each 30° of arc
+2. **The Houses** — 12 sectors of life experience, anchored to the local horizon
+3. **The Planets** (+ luminaries + points) — placed at their exact ecliptic longitudes
+4. **Aspects** — angular relationships between planets, drawn as lines across the centre
+
+---
+
+## 2. Coordinate System & Orientation
+
+### 2.1 The Circle
+
+- The wheel is a full **360° circle**
+- It is divided into **12 houses** and **12 zodiac signs** (these two grids do not have to align, depending on the house system)
+- The viewer is conceptually at the **centre**, looking outward — like standing inside a globe
+
+### 2.2 Cardinal Directions — IMPORTANT: East and West are flipped
+
+Because we look *outward from the centre*, the chart mirrors a compass:
+
+| Compass direction | Chart position | Angle on SVG canvas |
+|---|---|---|
+| **East** (sunrise) | **Left** (9 o'clock) | 180° |
+| **West** (sunset) | **Right** (3 o'clock) | 0° |
+| **South** (midday sun, highest point) | **Top** (12 o'clock) | 90° |
+| **North** (nadir, underground) | **Bottom** (6 o'clock) | 270° |
+
+> **Rule #1:** The Ascendant (ASC) is always on the **left**, on the horizontal axis. This is non-negotiable and universal across all Western chart styles.
+
+### 2.3 Degree Placement
+
+Ecliptic longitude runs **counter-clockwise** from 0° Aries:
+
+- 0° Aries starts at the ASC position (left, 9 o'clock) and increases counter-clockwise
+- To convert ecliptic longitude `λ` to an SVG angle (clockwise from 3 o'clock): `svgAngle = (180 - λ) mod 360`
+- Or equivalently, place the ASC at the left, and increase counter-clockwise from there
+
+---
+
+## 3. The Four Angles — The Skeleton of the Chart
+
+These four points are the most important structural elements. They form a **cross** (the "Cross of Matter") and divide the chart into four quadrants.
+
+```
+                    MC (Medium Coeli)
+                    Top / 12 o'clock
+                    "Midheaven" — career, public life
+                         |
+                         |
+West ——— DSC ————————————+———————————— ASC ——— East
+(Descendant)             |              (Ascendant)
+Right / 3 o'clock        |              Left / 9 o'clock
+Partnerships, others     |              Self, body, appearance
+                         |
+                    IC (Imum Coeli)
+                    Bottom / 6 o'clock
+                    "Nadir" — home, roots, private life
+```
+
+| Angle | Abbrev | Position | House cusp | Meaning |
+|---|---|---|---|---|
+| Ascendant | ASC / AC | Left, horizon | 1st house | Eastern horizon; rising sign; self, appearance |
+| Descendant | DSC / DC | Right, horizon | 7th house | Western horizon; partnerships, "the other" |
+| Midheaven | MC | Top, meridian | 10th house | Highest point; career, public status, ambitions |
+| Imum Coeli | IC | Bottom, meridian | 4th house | Lowest point; home, roots, ancestry |
+
+> The ASC–DSC axis is the **horizon line** (horizontal).  
+> The MC–IC axis is the **meridian line** (vertical).  
+> In most (but not all) house systems, these axes align with house cusps 1/7 and 10/4 respectively.
+
+---
+
+## 4. The 12 Houses
+
+Houses are numbered **1 through 12**, starting from the ASC and going **counter-clockwise**.
+
+```
+         10   11   12
+       9            1  ← ASC (left)
+       8            2
+         7    6    5   4   3
+                  ↑
+                  IC (bottom)
+```
+
+### House Number Layout (clock analogy)
+
+```
+            [11]  [10]  [9]
+        [12]                [8]
+    [1] ASC                     [7] DSC
+        [2]                 [6]
+            [3]   [4]   [5]
+                  IC
+```
+
+### Quadrant Groupings
+
+| Quadrant | Houses | Axis between | Theme |
+|---|---|---|---|
+| 1st | 1, 2, 3 | ASC → IC | Personal Identity, awareness of self |
+| 2nd | 4, 5, 6 | IC → DSC | Personal expression, integration with environment |
+| 3rd | 7, 8, 9 | DSC → MC | Social identity, awareness of others |
+| 4th | 10, 11, 12 | MC → ASC | Social expression, integration with society |
+
+### House Types
+
+| Type | Houses | Modality analog | Quality |
+|---|---|---|---|
+| Angular | 1, 4, 7, 10 | Cardinal | Most powerful; planets here are prominent |
+| Succedent | 2, 5, 8, 11 | Fixed | Stable; accumulate resources |
+| Cadent | 3, 6, 9, 12 | Mutable | Transitional; mental, service-oriented |
+
+### The 12 Houses — Traditional Meanings
+
+| House | Traditional name | Life domain |
+|---|---|---|
+| 1 | House of Self | Identity, body, appearance, first impressions |
+| 2 | House of Value | Money, possessions, self-worth, material resources |
+| 3 | House of Communication | Siblings, short trips, writing, local environment |
+| 4 | House of Home | Family, roots, ancestry, private life, real estate |
+| 5 | House of Pleasure | Creativity, romance, children, play, speculation |
+| 6 | House of Health | Daily routines, work, health, service, employees |
+| 7 | House of Partnership | Marriage, contracts, open enemies, one-to-one relating |
+| 8 | House of Transformation | Death, shared resources, sexuality, taxes, occult |
+| 9 | House of Philosophy | Higher education, travel, religion, law, publishing |
+| 10 | House of Career | Status, reputation, authority, vocation, father |
+| 11 | House of Community | Friends, groups, hopes, humanitarian causes |
+| 12 | House of Undoing | Secrets, isolation, karma, hidden enemies, institutions |
+
+---
+
+## 5. House Systems
+
+The **zodiac** is fixed; the **houses** depend on birth time and location. Different house systems calculate house cusps differently.
+
+| System | Description | Common use |
+|---|---|---|
+| **Placidus** | Divides each quadrant by the time it takes a degree to travel from ASC to MC; produces unequal house sizes | Most common in modern Western astrology |
+| **Whole Sign** | Each house = one entire zodiac sign; ASC sign = House 1; very simple | Traditional / Hellenistic astrology; increasingly popular |
+| **Equal House** | All houses exactly 30°, starting from ASC degree | Clean rendering; used by some modern and Vedic astrologers |
+| **Koch** | Similar to Placidus but uses a different trisection method | Common in German-speaking countries |
+| **Campanus** | Divides the prime vertical; produces differently shaped houses | Rare, used by some traditional practitioners |
+| **Porphyry** | Divides each quadrant into three equal parts | Simple alternative to Placidus |
+
+> **Rendering implication:** Placidus (and Koch, Porphyry) produce **wedges of unequal angular width**. Whole Sign and Equal House produce **equal 30° slices**. The chart wheel must reflect the actual cusp degrees, not assume equal slices.
+
+---
+
+## 6. The Zodiac Band
+
+The outermost ring of the wheel displays the **12 zodiac signs** in counter-clockwise order, each occupying exactly 30°. Their order is always fixed:
+
+| # | Sign | Glyph | Element | Modality | Unicode |
+|---|---|---|---|---|---|
+| 1 | Aries | ♈ | Fire | Cardinal | U+2648 |
+| 2 | Taurus | ♉ | Earth | Fixed | U+2649 |
+| 3 | Gemini | ♊ | Air | Mutable | U+264A |
+| 4 | Cancer | ♋ | Water | Cardinal | U+264B |
+| 5 | Leo | ♌ | Fire | Fixed | U+264C |
+| 6 | Virgo | ♍ | Earth | Mutable | U+264D |
+| 7 | Libra | ♎ | Air | Cardinal | U+264E |
+| 8 | Scorpio | ♏ | Water | Fixed | U+264F |
+| 9 | Sagittarius | ♐ | Fire | Mutable | U+2650 |
+| 10 | Capricorn | ♑ | Earth | Cardinal | U+2651 |
+| 11 | Aquarius | ♒ | Air | Fixed | U+2652 |
+| 12 | Pisces | ♓ | Water | Mutable | U+2653 |
+
+The zodiac band starts at **0° Aries** (not necessarily at the 9 o'clock position — its starting point rotates based on the ASC). The Ascendant degree within a sign is placed at the 9 o'clock position, and the entire zodiac band rotates accordingly.
+
+### Element Colour Coding (common convention)
+
+| Element | Signs | Colour |
+|---|---|---|
+| Fire | Aries, Leo, Sagittarius | Red / orange |
+| Earth | Taurus, Virgo, Capricorn | Green / brown |
+| Air | Gemini, Libra, Aquarius | Yellow / sky blue |
+| Water | Cancer, Scorpio, Pisces | Blue / teal |
+
+---
+
+## 7. The Planets & Points
+
+### 7.1 Planet Glyphs
+
+| Body | Glyph | Unicode | Category |
+|---|---|---|---|
+| Sun ☉ | ☉ | U+2609 | Luminary |
+| Moon ☽ | ☽ | U+263D | Luminary |
+| Mercury ☿ | ☿ | U+263F | Inner planet |
+| Venus ♀ | ♀ | U+2640 | Inner planet |
+| Mars ♂ | ♂ | U+2642 | Inner planet |
+| Jupiter ♃ | ♃ | U+2643 | Social planet |
+| Saturn ♄ | ♄ | U+2644 | Social planet |
+| Uranus ♅ | ♅ | U+2645 | Outer / modern |
+| Neptune ♆ | ♆ | U+2646 | Outer / modern |
+| Pluto ♇ | ♇ | U+2647 | Outer / modern |
+| North Node ☊ | ☊ | U+260A | Point |
+| South Node ☋ | ☋ | U+260B | Point |
+| Chiron ⚷ | ⚷ | U+26B7 | Asteroid / modern |
+| Part of Fortune ⊕ | ⊕ | — | Arabic lot |
+
+> **Traditional astrology** uses only the 7 classical planets: Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn.  
+> **Modern astrology** adds Uranus, Neptune, Pluto, Chiron, the Lunar Nodes, and sometimes asteroids (Ceres, Juno, Pallas, Vesta).
+
+### 7.2 Planet Placement
+
+Each planet is placed at its **ecliptic longitude** (degrees + minutes within a sign). The planet glyph is rendered:
+- Inside the house ring (between the house cusp lines and the chart centre)
+- At the correct angular position around the wheel
+- With a small label showing degree and minutes: e.g., `♀ 14°37'`
+- A tick mark or line from the planet glyph to the exact degree on the zodiac ring is common
+
+**Collision avoidance:** Multiple planets in the same house or close degrees will overlap. Standard solutions:
+- Cluster glyphs and spread them slightly within the sector
+- Draw a fine line from each glyph to its exact tick mark on the zodiac ring
+- Use a minimum angular spacing (e.g. 5°) between glyphs
+
+### 7.3 Retrograde
+
+A planet in retrograde motion is marked with **Rx** or **℞** next to its glyph. This is displayed on the wheel and in data tables.
+
+---
+
+## 8. Aspects
+
+Aspects are **angular relationships** between planets, drawn as lines across the centre of the wheel.
+
+### 8.1 Major Aspects
+
+| Aspect | Symbol | Angle | Orb (modern) | Nature | Colour convention |
+|---|---|---|---|---|---|
+| Conjunction | ☌ | 0° | ±8° | Neutral / intense | Yellow or grey |
+| Opposition | ☍ | 180° | ±8° | Tense / polarising | Red |
+| Square | □ | 90° | ±8° | Tense / challenging | Red |
+| Trine | △ | 120° | ±8° | Harmonious | Blue |
+| Sextile | ✱ | 60° | ±6° | Harmonious / mild | Green |
+
+### 8.2 Minor Aspects
+
+| Aspect | Symbol | Angle | Orb | Nature |
+|---|---|---|---|---|
+| Quincunx (Inconjunct) | ⚻ | 150° | ±3° | Mildly challenging |
+| Semi-sextile | ⚺ | 30° | ±2° | Mildly harmonious |
+| Semi-square | ∠ | 45° | ±2° | Mildly tense |
+| Sesquiquadrate | ⚼ | 135° | ±2° | Mildly tense |
+| Quintile | Q | 72° | ±2° | Creative |
+| Biquintile | bQ | 144° | ±2° | Creative |
+
+### 8.3 Rendering Aspects
+
+- Lines are drawn **between the two planet positions**, straight across the chart centre
+- Colour-code by aspect type (see above)
+- Line style can vary: solid for major, dashed for minor
+- Optionally show an aspect grid table (see Section 11)
+- Traditional charts often omit minor aspects from the wheel to reduce clutter
+
+---
+
+## 9. Visual Layer Stack (outermost → innermost)
+
+```
+[Layer 5] Zodiac sign band       — outermost ring; 12 coloured segments, sign glyphs
+[Layer 4] House cusp tick marks  — radial lines at each cusp degree on zodiac ring
+[Layer 3] House ring             — labelled with house numbers (1–12)
+[Layer 2] Planet ring            — planet glyphs with degree labels + Rx markers
+[Layer 1] Aspect web             — lines connecting planets across centre
+[Layer 0] Centre circle          — sometimes blank, sometimes shows subject name/chart data
+```
+
+Typical radii (as percentage of total wheel radius, working outward from 0):
+
+| Region | Inner radius | Outer radius |
+|---|---|---|
+| Aspect web / centre | 0% | 35% |
+| Planet glyph ring | 35% | 60% |
+| House number labels | 60% | 68% |
+| House cusp lines | 35% | 78% |
+| Inner zodiac band edge | 78% | — |
+| Zodiac sign glyphs | 78% | 90% |
+| Outer zodiac band edge | 90% | — |
+| Degree tick marks | 90% | 100% |
+
+> These ratios can be adjusted; this is a common professional proportion. The key constraint is that the zodiac band is always outermost, and planets sit inside the house ring.
+
+---
+
+## 10. Chart Header / Subject Information
+
+Many (but not all) charts display a **data block** either inside the centre circle or in a box outside the wheel:
+
+- **Subject's name** (if personal chart)
+- **Date of birth**: day, month, year
+- **Time of birth**: HH:MM (+ timezone or UTC offset)
+- **Place of birth**: city, country
+- **Latitude / Longitude**
+- **House system used** (e.g. Placidus)
+- **Zodiac type** (Tropical or Sidereal)
+
+For anonymous or event charts, the name is omitted. Time and place are almost always shown.
+
+---
+
+## 11. Accompanying Data Tables
+
+Professional chart printouts (and most software) include one or more tables alongside the wheel.
+
+### 11.1 Planet Positions Table
+
+The most essential table. Rows = planets, Columns = sign, degree/minute, house, retrograde status.
+
+```
+Planet    | Sign        | Degree   | House | Rx
+----------|-------------|----------|-------|----
+☉ Sun     | ♌ Leo       | 14° 22'  |   5   |
+☽ Moon    | ♑ Capricorn | 07° 58'  |  10   |
+☿ Mercury | ♌ Leo       | 28° 11'  |   5   | Rx
+♀ Venus   | ♎ Libra     | 02° 45'  |   7   |
+♂ Mars    | ♊ Gemini    | 19° 03'  |   3   |
+♃ Jupiter | ♐ Sagittarius| 11° 30' |   9   |
+♄ Saturn  | ♒ Aquarius  | 06° 21'  |  11   | Rx
+♅ Uranus  | ♉ Taurus    | 14° 07'  |   2   |
+♆ Neptune | ♓ Pisces    | 22° 55'  |  12   |
+♇ Pluto   | ♑ Capricorn | 26° 17'  |  10   | Rx
+☊ N. Node | ♊ Gemini    | 00° 48'  |   3   |
+ASC       | ♍ Virgo     | 22° 15'  |   1   |
+MC        | ♊ Gemini    | 20° 48'  |  10   |
+```
+
+### 11.2 House Cusps Table
+
+Shows the zodiac degree at the beginning (cusp) of each house.
+
+```
+House | Sign        | Degree
+------|-------------|--------
+  1   | ♍ Virgo     | 22° 15'   (= ASC)
+  2   | ♎ Libra     | 18° 42'
+  3   | ♏ Scorpio   | 17° 30'
+  4   | ♐ Sagittarius| 19° 10'  (= IC)
+  5   | ♑ Capricorn | 21° 48'
+  6   | ♒ Aquarius  | 22° 30'
+  7   | ♓ Pisces    | 22° 15'   (= DSC)
+  8   | ♈ Aries     | 18° 42'
+  9   | ♉ Taurus    | 17° 30'
+ 10   | ♊ Gemini    | 19° 10'   (= MC)
+ 11   | ♋ Cancer    | 21° 48'
+ 12   | ♌ Leo       | 22° 30'
+```
+
+### 11.3 Aspects Grid (Aspect Matrix)
+
+A triangular grid showing all planet-to-planet aspects. Rows and columns are planets; intersecting cells show the aspect symbol (if one exists within orb) or blank.
+
+```
+      ☉  ☽  ☿  ♀  ♂  ♃  ♄  ♅  ♆  ♇
+  ☉   –
+  ☽   □  –
+  ☿   ☌  △  –
+  ♀   ✱  ☍  ✱  –
+  ♂   △  □  △  ✱  –
+  ♃   ☍  ✱  ☍  □  △  –
+  ♄   □  ✱  □  △  ☍  △  –
+  ...
+```
+
+Colour-code each symbol by aspect type for readability.
+
+### 11.4 Elements & Modalities Balance Table
+
+Summary count of how many planets fall in each element and modality. Common in modern psychological astrology.
+
+```
+Elements:          Modalities:
+Fire  ♈♌♐  → 3   Cardinal ♈♋♎♑ → 4
+Earth ♉♍♑  → 2   Fixed    ♉♌♏♒ → 3
+Air   ♊♎♒  → 3   Mutable  ♊♍♐♓ → 3
+Water ♋♏♓  → 2
+```
+
+### 11.5 Dignities Table (Traditional charts)
+
+Shows each planet's **essential dignity** — how well or poorly placed it is in its sign:
+
+| Planet | Sign | Dignity |
+|---|---|---|
+| ☉ Sun | ♌ Leo | Domicile (home sign) |
+| ☽ Moon | ♉ Taurus | Exaltation |
+| ♂ Mars | ♑ Capricorn | Exaltation |
+| ♄ Saturn | ♓ Pisces | Fall |
+| ♀ Venus | ♏ Scorpio | Detriment |
+
+Dignity levels: **Domicile > Exaltation > Triplicity > Term > Face > Peregrine > Detriment > Fall**
+
+---
+
+## 12. Traditional vs. Modern Chart Layouts
+
+### 12.1 Traditional Western (Hellenistic / Medieval)
+
+- **House system:** Whole Sign most common; also Alcabitius, Porphyry
+- **Planets used:** 7 classical (Sun, Moon, Mercury, Venus, Mars, Jupiter, Saturn)
+- **Outer planets:** Not used or shown separately as "modern additions"
+- **Aspects:** Primarily the 5 Ptolemaic aspects (conjunction, sextile, square, trine, opposition)
+- **Dignities table:** Central to interpretation; always shown
+- **Arabic lots:** Part of Fortune and others may be plotted on the wheel
+- **Wheel style:** Often simpler, less cluttered; may use bolder house division lines
+- **Sect:** Day/night birth distinction noted; planets classified as diurnal or nocturnal
+- **Colour:** Often black and white or minimal colour; functional over decorative
+
+### 12.2 Modern Western (Psychological / Humanistic)
+
+- **House system:** Placidus (default), Koch, or Equal; houses of unequal size
+- **Planets used:** 10 (add Uranus, Neptune, Pluto) + Chiron + Nodes
+- **Aspects:** All 5 major + minor aspects (quincunx, semi-square, etc.)
+- **Dignities:** Often de-emphasised or omitted ("no planet is inherently bad")
+- **Arabic lots:** Rarely shown
+- **Wheel style:** More visual, colourful; aspect lines colour-coded; glyphs styled
+- **Elements/modalities table:** Almost always included
+- **Chart data block:** Usually shown prominently
+- **Outer ring:** May include a second ring for transits (biwheel) or progressions
+
+### 12.3 Square / Table Chart (Vedic / Jyotish, some North Indian tradition)
+
+Not circular — uses a **diamond or grid** layout:
+
+- North Indian style: Fixed diamond grid; houses are boxes, signs rotate
+- South Indian style: Fixed signs in boxes; houses rotate
+- No aspect lines drawn; aspects calculated by sign and house
+- Not the focus here, but worth knowing this exists
+
+### 12.4 Biwheel Chart (Transit / Synastry)
+
+Two concentric wheels:
+- **Inner wheel:** Natal chart (birth data)
+- **Outer wheel:** Transit positions (current sky), progressions, or partner's natal chart
+- Aspect lines drawn between inner and outer planets
+- Planet glyphs in the outer ring are often styled differently (lighter, or different colour)
+
+---
+
+## 13. What Goes Where — Spatial Summary
+
+```
+OUTSIDE the wheel:
+  - Chart title / subject name (top or header area)
+  - Birth data (date, time, place) — top or bottom margin
+  - Optional: House system label
+
+OUTERMOST ring (zodiac band):
+  - 12 sign glyphs, one per 30° sector
+  - Degree tick marks (every 1° or every 5°)
+  - Sign dividers (bold lines every 30°)
+  - Element colour fill per sign sector
+
+NEXT ring inward (house cusps):
+  - Cusp lines radiating from centre to zodiac ring
+  - House number labels (1–12), placed in the middle of each house sector
+  - Angular house cusps (1, 4, 7, 10) drawn as bolder/heavier lines
+
+PLANET ring:
+  - Planet glyphs at their ecliptic longitude
+  - Degree + minute label beside each glyph
+  - Rx symbol for retrograde planets
+  - Fine lines from glyph to exact tick mark on zodiac band (when glyphs are nudged apart for readability)
+
+CENTRE of wheel:
+  - Aspect lines (coloured by type)
+  - Sometimes: subject name, chart date, or decorative element
+  - Four angles may be labelled (ASC, DSC, MC, IC) just inside the cusp lines
+
+OUTSIDE or BELOW the wheel (tables):
+  - Planet positions table
+  - House cusps table
+  - Aspects grid / matrix
+  - Elements & modalities table
+  - Dignities table (traditional)
+```
+
+---
+
+## 14. The Angles on the Wheel — Rendering Detail
+
+The four angles must be visually prominent:
+
+- The **horizontal axis** (ASC ↔ DSC) is drawn as a solid line across the wheel at the 9 o'clock / 3 o'clock positions
+- The **vertical axis** (MC ↔ IC) is drawn as a solid line at 12 o'clock / 6 o'clock
+- Labels **ASC** (or AC), **DSC** (or DC), **MC**, **IC** are placed just outside the wheel at each axis endpoint
+- The degree and sign of each angle should be shown: e.g. `ASC 22° ♍`
+- The house 1 cusp line and ASC line are typically the same line
+
+---
+
+## 15. Typography & Glyph Notes
+
+- Astrological glyphs are available as Unicode characters (see tables above) and render in most modern fonts
+- Dedicated astrology fonts (e.g. *Astro*, *Starfont*, *Hamburg* symbol sets) provide better glyph shapes
+- Glyphs on the wheel should be **large enough to read at a glance** — typically 14–18px minimum
+- SVG `text` elements or `<tspan>` can be rotated along the arc of each house sector for degree labels
+- **Never rotate planet glyphs** — they should always be upright, even when placed around a circular ring
+- Degree labels can be slightly rotated to follow the ring
+
+---
+
+## 16. Minimum Viable Chart vs. Full Chart
+
+### Minimum Viable (wheel only)
+- Zodiac ring with 12 signs
+- House divisions (Whole Sign is simplest: equal 30° slices)
+- Planet glyphs at correct positions
+- ASC/MC axes labelled
+
+### Standard (most software default)
+- All of the above
+- Placidus house cusps (unequal)
+- Aspects drawn as coloured lines
+- Degree labels on planets
+- Planet positions table alongside wheel
+- ASC, DSC, MC, IC labelled
+
+### Full Professional
+- All of the above
+- Aspect grid table
+- Elements/modalities table
+- House cusps table
+- Dignities (optional)
+- Rx markers
+- North/South Node plotted
+- Chiron plotted
+- Part of Fortune plotted
+- Subject name + birth data block
+
+---
+
+## 17. Common Pitfalls
+
+1. **Clockwise vs. counter-clockwise:** Houses are numbered counter-clockwise. Zodiac signs also increase counter-clockwise. On a standard SVG, Y increases downward, so convert carefully.
+2. **ASC rotation:** The ASC degree must land at exactly 9 o'clock (left). Rotate the entire zodiac band so the ASC degree is at 180° on an SVG (pointing left).
+3. **Equal vs. unequal slices:** Do not assume all 12 houses are 30°. With Placidus, use the actual cusp degrees to draw each wedge.
+4. **Planet glyph overlap:** Implement collision avoidance; spread glyphs and draw connecting lines to the true degree.
+5. **Retrograde direction:** Rx does NOT change the planet's position on the chart — it is only a label.
+6. **Axis labelling confusion:** ASC is left (east), DSC is right (west) — opposite of a standard map.
+
+---
+
+## 18. Reference: Aspect Orb Defaults (Placidus / Modern Western)
+
+| Aspect | Degrees | Default orb | Tight orb |
+|---|---|---|---|
+| Conjunction | 0° | ±8° | ±3° |
+| Opposition | 180° | ±8° | ±3° |
+| Square | 90° | ±7° | ±3° |
+| Trine | 120° | ±7° | ±3° |
+| Sextile | 60° | ±6° | ±2° |
+| Quincunx | 150° | ±3° | ±1° |
+| Semi-square | 45° | ±2° | ±1° |
+| Semi-sextile | 30° | ±2° | ±1° |
+
+Traditional astrology uses tighter orbs, applies orbs to the **signs** rather than exact degrees in some systems (sign-based aspects), and considers whether a planet is "applying" (moving toward exact) or "separating" (moving away).
+
+---
+
+*This document describes Western tropical astrology conventions. Vedic/Jyotish astrology uses different coordinate systems (sidereal zodiac), different house systems, and a different visual format — square charts rather than the circular wheel described here.*

+ 330 - 0
docs/chart-rendering-api-design.md

@@ -0,0 +1,330 @@
+# Chart Rendering — API Design: MCP Tools vs Separate Renderer
+
+**Date:** 2026-06-07
+**Status:** Design discussion — decide before coding
+
+---
+
+## The Core Question
+
+Should chart rendering be **inside** existing MCP tools (via `include_svg` param),
+or should it be **separate** dedicated render tools?
+
+---
+
+## Option A: `include_svg` on Existing Tools
+
+### How it works
+
+```python
+@mcp.tool()
+async def calculate_natal_chart(
+    birth_datetime: str,
+    latitude: float,
+    longitude: float,
+    # ... existing params ...
+    include_svg: bool = False,
+    svg_style: str = "modern",       # "modern" | "traditional" | "minimal"
+    svg_color: str = "color",        # "color" | "bw"
+    svg_size: int = 600,             # pixel width
+) -> dict[str, Any]:
+    ...
+    result = { ...planets, houses, aspects... }
+    if include_svg:
+        result["chart_svg"] = render_natal_wheel(result, style=svg_style, ...)
+    return result
+```
+
+Same pattern for `calculate_transit_chart`, `calculate_synastry_chart`,
+`calculate_composite_chart`, `calculate_davison_chart`, and all `_byId` variants.
+
+### Pros
+- **One call gets everything** — data + visual in one shot
+- Agent workflow is simpler: "calculate my chart" → gets SVG to display
+- No extra round-trips
+- Backward compatible (default `include_svg=false`)
+
+### Cons
+- **Bloats the response** — a full SVG is ~20-50KB of XML text. When you only
+  want data (most API calls), you pay the cost anyway (even if just in
+  description length for the LLM context).
+- **Mixes concerns** — the calculation tool now also renders. Makes the function
+  harder to maintain.
+- **Style params proliferate** — every chart tool gets 3-4 extra SVG params.
+  With 10 chart tools, that's 30-40 extra parameters to document.
+- **Can't render without recalculating** — if you already have the chart data
+  (e.g. from a previous call or from the DB), you can't just "render it" without
+  re-doing the ephemeris calls.
+- **SVG in JSON is ugly** — escaped XML inside JSON is painful to read/debug.
+
+### Verdict
+**Not recommended** for the main path. The response bloat alone is a problem —
+an LLM calling `calculate_natal_chart` for data processing gets 50KB of SVG
+XML forced into its context window every time.
+
+---
+
+## Option B: Separate Dedicated Render Tools (RECOMMENDED)
+
+### How it works
+
+```python
+@mcp.tool()
+async def render_natal_chart(
+    chart_data: dict,
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    format: str = "svg",         # "svg" | "pdf" | "png"
+) -> dict[str, Any]:
+    """Render a natal chart wheel from chart calculation data.
+
+    Takes the output of calculate_natal_chart (or any chart tool) and
+    renders it as a visual chart wheel. The chart_data parameter accepts
+    the full dict returned by any chart calculation tool.
+    """
+    ...
+    return {
+        "svg": "<svg>...</svg>",     # or base64 png, or pdf path
+        "format": "svg",
+        "width": size,
+        "height": size,
+    }
+```
+
+Plus convenience variants:
+
+```python
+@mcp.tool()
+async def render_natal_chart_by_id(
+    person_id: str,
+    house_system: str = "placidus",
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    include_table: bool = True,      # include aspect table below wheel
+    include_planet_list: bool = True,
+) -> dict[str, Any]:
+    """Render natal chart for a person from the database.
+
+    Combines calculate_natal_chart + render_natal_chart in one call.
+    Fetches birth data, calculates positions, and renders the wheel.
+    """
+```
+
+### Pros
+- **Clean separation** — calculation tools stay lean, rendering is separate
+- **Flexible** — render any chart data, even from external sources
+- **Multiple formats** — SVG for web, PDF for print, PNG for thumbnails
+- **Caching friendly** — cache rendered SVG by hash of chart data + style params
+- **Agent-friendly** — agent can do:
+  1. `data = calculate_natal_chart(...)` → gets lean JSON
+  2. `svg = render_natal_chart(data)` → gets SVG only when needed
+  3. Or skip step 1: `svg = render_natal_chart_by_id("lucky")` → one-shot
+- **Dashboard-friendly** — HTTP routes can call the same renderer
+
+### Cons
+- Two tool calls if you want both data + SVG (extra round-trip)
+- `render_natal_chart_by_id` duplicates the param lists of the data tools
+
+### Verdict
+**Recommended.** Clean architecture, flexible, doesn't bloat the data tools.
+
+---
+
+## Option C: Hybrid (Data tools + Render Resource)
+
+### The idea
+
+Keep data tools pure. Add a **separate render tool** that accepts either
+chart data OR a person_id:
+
+```python
+@mcp.tool()
+async def render_chart(
+    # One of these two is required:
+    chart_data: dict | None = None,
+    person_id: str | None = None,
+    # Rendering options:
+    chart_type: str = "natal",       # "natal" | "transit" | "synastry" | ...
+    style: str = "modern",
+    color_mode: str = "color",
+    format: str = "svg",
+    size: int = 600,
+    transit_date: str | None = None,  # for transit charts
+    person2_id: str | None = None,    # for synastry
+) -> dict[str, Any]:
+    """Universal chart renderer.
+
+    Pass chart_data from any chart tool output, OR pass person_id to
+    auto-calculate + render in one step.
+    """
+```
+
+### Pros
+- Single render tool, not 10+ variants
+- Flexible input: raw data or person DB lookup
+- Easy to extend with new chart types
+
+### Cons
+- Complex parameter validation (mutually exclusive groups)
+- Docstring becomes very long
+- Too many responsibilities in one function
+
+### Verdict
+Nice in theory, messy in practice. Go with Option B.
+
+---
+
+## Recommended Design: Option B (Separated)
+
+### Tool Inventory
+
+**Core data tools (unchanged, stay lean):**
+- `calculate_natal_chart` — pure data
+- `calculate_transit_chart` — pure data
+- `calculate_synastry_chart` — pure data
+- `calculate_composite_chart` — pure data
+- `calculate_davison_chart` — pure data
+- All `_byId` variants — pure data
+
+**New render tools:**
+
+| Tool | Purpose |
+|------|---------|
+| `render_natal_chart` | Render natal wheel from chart_data |
+| `render_transit_chart` | Render bi-wheel from transit chart_data |
+| `render_synastry_chart` | Render dual wheel from synastry chart_data |
+| `render_composite_chart` | Render composite wheel from chart_data |
+| `render_davison_chart` | Render Davison wheel from chart_data |
+| `render_natal_chart_by_id` | One-shot: fetch DB + calc + render natal |
+| `render_transit_chart_by_id` | One-shot: fetch DB + calc + render transit |
+| `render_synastry_chart_by_id` | One-shot: fetch DB both + calc + render synastry |
+
+### Render Options (consistent across all render tools)
+
+```python
+style: str = "modern"        # "modern" | "traditional" | "minimal"
+color_mode: str = "color"    # "color" | "bw"
+size: int = 600              # SVG viewBox width in pixels (square)
+format: str = "svg"          # "svg" | "pdf" | "png"
+include_table: bool = false  # Aspect table below wheel
+include_planets: bool = false # Planet data table
+include_houses: bool = false  # House cusp table
+title: str | None = None     # Custom title (default: auto-generated)
+font_family: str = "astronomicon"  # "astronomicon" | "unicode"
+```
+
+### Return Structure
+
+```python
+{
+    "svg": "<svg xmlns=...>...</svg>",    # SVG string (when format="svg")
+    # OR
+    "pdf_b64": "JVBERi0xLjQK...",         # base64 PDF (when format="pdf")
+    # OR
+    "png_b64": "iVBORw0KGgo...",         # base64 PNG (when format="png")
+    "format": "svg",
+    "width": 600,
+    "height": 600,
+    "style": "modern",
+    "color_mode": "color",
+    "included": ["wheel", "table", "planets"],
+    "svg_size_bytes": 28341,
+}
+```
+
+### Agent Workflow Examples
+
+**Use case 1: Agent wants to show a chart in chat**
+```python
+# One call — agent gives SVG to user
+svg_result = render_natal_chart_by_id("lucky", style="modern")
+# Agent outputs: chart_svg string → user sees the wheel
+```
+
+**Use case 2: Agent wants data analysis + chart**
+```python
+# Step 1: Get data (lean, fast)
+data = calculate_natal_chart_by_id("lucky", include_overview=true)
+# Agent analyzes: "Sun in Leo, Moon in Cancer..."
+
+# Step 2: Get chart only if needed
+svg = render_natal_chart(data, style="traditional", color_mode="bw")
+# Agent attaches SVG to response
+```
+
+**Use case 3: Dashboard/web display**
+```
+GET /dashboard/charts/person/{id}/natal?style=modern&color=width=800
+→ Calls render_natal_chart internally → returns SVG inline in HTML
+```
+
+**Use case 4: Print PDF**
+```python
+pdf = render_natal_chart_by_id("lucky", format="pdf", style="traditional",
+                                 include_table=true, include_houses=true)
+# pdf["pdf_b64"] → decode → write to file → send to printer
+```
+
+---
+
+## Implementation Plan
+
+### Phase 1: Renderer Core
+- `svgwrite` for SVG generation
+- Layout engine: zodiac ring, house sectors, planet positions, aspect lines
+- Style system: modern/traditional, color/BW
+- Astronomicon font integration via `@font-face` + unicode fallback
+- Output: SVG string
+
+### Phase 2: Render Tools
+- `render_natal_chart` + `render_natal_chart_by_id`
+- PDF export via WeasyPrint
+- PNG export via cairosvg
+- Aspect table SVG
+- Planet/house table SVGs
+
+### Phase 3: Additional Chart Types
+- Transit bi-wheel
+- Synastry dual wheel
+- Composite wheel
+- Davison wheel
+
+### Phase 4: Dashboard
+- `/dashboard/charts/` routes
+- Style picker, format picker, download buttons
+- Person lookup → chart display
+- Transit date slider
+
+---
+
+## Open Questions
+
+1. **Render tool count**: 8 render tools (5 chart types + 3 byId) seems like a lot.
+   Could collapse to just `render_chart(chart_data, chart_type)` +
+   `render_chart_by_id(person_id, chart_type)` with a `chart_type` enum.
+   → **Lean toward 2 universal tools** to keep the surface small.
+
+2. **Chart data validation**: What if the agent passes malformed `chart_data`?
+   → Use a Pydantic model to validate the expected structure, return
+   clear error messages.
+
+3. **SVG size limits**: A full chart SVG can be 30-60KB. MCP tool responses
+   should handle this fine, but some LLM context windows may not appreciate it.
+   → Consider a `compact` mode that strips comments/whitespace from SVG.
+
+4. **Transit chart render**: Needs two sets of planet positions + aspect lines.
+   Bi-wheel layout (inner natal + outer transit) or side-by-side?
+   → **Bi-wheel** is the standard, but side-by-side is clearer for >15 aspects.
+   Support both, default to bi-wheel.
+
+5. **Synastry chart render**: Two natal wheels + interaspect lines?
+   → Side-by-side wheels with a middle column of key interaspects.
+   Bi-wheel (person A inner, person B outer) for the visual.
+
+6. **Caching**: SVG is deterministic for (chart_data_hash + style_options).
+   → Cache in memory with LRU eviction. Key: sha256(chart_data + options).
+
+7. **Person info on chart**: Name, birth date, location in the title block?
+   → Extracted from chart_data["input"] if present. Respect person.private flag.

+ 911 - 0
src/astro_mcp/chart_renderer.py

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

+ 11 - 0
src/astro_mcp/server.py

@@ -52,6 +52,17 @@ def _tool_names() -> list[str]:
         "calculate_composite_chart_by_id",
         "calculate_davison_chart_by_id",
         "get_transit_preview_by_id",
+        # Chart rendering tools
+        "render_natal_chart",
+        "render_natal_chart_by_id",
+        "render_transit_chart",
+        "render_transit_chart_by_id",
+        "render_synastry_chart",
+        "render_synastry_chart_by_id",
+        "render_composite_chart",
+        "render_composite_chart_by_id",
+        "render_davison_chart",
+        "render_davison_chart_by_id",
     ]
 
 

+ 830 - 0
src/astro_mcp/tools.py

@@ -1836,3 +1836,833 @@ Returns:
         transit_longitude=transit_longitude,
         min_significance=min_significance,
     )
+
+
+# ═══════════════════════════════════════════════════════════════════════
+# CHART RENDERING TOOLS
+# ═══════════════════════════════════════════════════════════════════════
+# These tools render visual chart wheels from birth data.
+# They combine calculation + rendering in one step.
+# For data-only output, use the calculate_* tools instead.
+# ═══════════════════════════════════════════════════════════════════════
+
+# ── Shared render options (used by all render_* tools) ────────────────
+
+_RENDER_STYLE_HELP = (
+    "Chart visual style: 'modern' (clean, minimal), 'traditional' "
+    "(ornate, classical), or 'minimal' (bare bones)."
+)
+_RENDER_COLOR_HELP = (
+    "Color mode: 'color' (full color with element-themed zodiac ring), "
+    "'bw' (black/white, aspect lines distinguished by style), or "
+    "'dark' (dark background for web display)."
+)
+_RENDER_SIZE_HELP = "SVG width/height in pixels (default: 600)."
+_RENDER_TABLE_HELP = "Include an aspect table below the wheel."
+_RENDER_PLANETS_HELP = "Include a planet data table below the wheel."
+_RENDER_HOUSES_HELP = "Include a house cusp table below the wheel."
+_RENDER_TITLE_HELP = "Custom chart title. Auto-generated if not provided."
+
+
+# ── render_natal_chart ────────────────────────────────────────────────
+
+@mcp.tool()
+async def render_natal_chart(
+    # ── Birth data (same as calculate_natal_chart) ──────────────────
+    birth_datetime: str,
+    latitude: float,
+    longitude: float,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+    subtitle: str | None = None,
+) -> dict[str, Any]:
+    """Render a natal chart wheel as SVG.
+
+    Calculates planetary positions and renders a visual zodiac wheel with
+    planets, houses, and aspect lines. The SVG can be displayed in a browser,
+    converted to PDF/PNG, or attached to a chat.
+
+    BIRTH DATA (required):
+        birth_datetime: ISO 8601 datetime with timezone (e.g. "1990-05-15T10:30:00+01:00").
+        latitude: Birth latitude in decimal degrees (-90 to 90).
+        longitude: Birth longitude in decimal degrees (-180 to 180).
+
+    BIRTH DATA (optional):
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides, e.g. {"conjunction": 10}.
+        top_n_aspects: Limit aspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait layout with tables
+            under the wheel), or "right" (landscape layout with tables to the right).
+        include_planets: Include a planet data table (requires table_position != "none").
+        include_houses: Include a house cusp table (requires table_position != "none").
+        title: Custom chart title. Auto-generated if not provided.
+        subtitle: Custom subtitle. Auto-generated from birth data if not provided.
+
+    Returns:
+        Dict with "svg" (SVG string), "format" ("svg"), "width", "height",
+        and "included" (list of what's in the chart).
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_natal_chart(
+        birth_datetime=birth_datetime,
+        latitude=latitude,
+        longitude=longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+        subtitle=subtitle,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": _included_list(table_position, include_planets, include_houses),
+    }
+
+
+# ── render_natal_chart_by_id ──────────────────────────────────────────
+
+@mcp.tool()
+async def render_natal_chart_by_id(
+    # ── Person lookup (same as calculate_natal_chart_by_id) ─────────
+    person_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a natal chart wheel for a person from the database.
+
+    Looks up birth data by person_id or nickname, calculates the chart,
+    and renders it as an SVG wheel. Same rendering options as render_natal_chart.
+
+    PERSON LOOKUP (required):
+        person_id: ID or nickname of a person in the persons database.
+
+    PERSON LOOKUP (optional):
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+        top_n_aspects: Limit aspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg" (SVG string), "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_natal_chart_by_id(
+        person_id=person_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": _included_list(table_position, include_planets, include_houses),
+    }
+
+
+# ── render_transit_chart ──────────────────────────────────────────────
+
+@mcp.tool()
+async def render_transit_chart(
+    # ── Birth data + transit date (same as calculate_transit_chart) ─
+    birth_datetime: str,
+    transit_datetime: str,
+    latitude: float,
+    longitude: float,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a transit chart as a bi-wheel (natal inner, transit outer).
+
+    Calculates natal and transiting planet positions, then renders a bi-wheel
+    showing natal planets inside and transiting planets outside, with
+    transit-to-natal aspect lines.
+
+    BIRTH DATA (required):
+        birth_datetime: ISO 8601 birth datetime with timezone.
+        transit_datetime: ISO 8601 transit datetime (the "now" or future date).
+        latitude: Birth latitude in decimal degrees.
+        longitude: Birth longitude in decimal degrees.
+
+    TRANSIT LOCATION (optional):
+        transit_latitude: Location latitude for transit calculation. Defaults to birth latitude.
+        transit_longitude: Location longitude for transit calculation. Defaults to birth longitude.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg" (SVG string), "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_transit_wheel
+
+    chart_data = await calculate_transit_chart(
+        birth_datetime=birth_datetime,
+        transit_datetime=transit_datetime,
+        latitude=latitude,
+        longitude=longitude,
+        transit_latitude=transit_latitude,
+        transit_longitude=transit_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_transit_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": ["wheel"],
+    }
+
+
+# ── render_transit_chart_by_id ────────────────────────────────────────
+
+@mcp.tool()
+async def render_transit_chart_by_id(
+    # ── Person lookup + transit date ────────────────────────────────
+    person_id: str,
+    transit_datetime: str,
+    transit_latitude: float | None = None,
+    transit_longitude: float | None = None,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a transit bi-wheel for a person from the database.
+
+    Looks up birth data by person_id, calculates transits for the given
+    date, and renders a bi-wheel chart.
+
+    PERSON LOOKUP (required):
+        person_id: ID or nickname of a person in the persons database.
+        transit_datetime: ISO 8601 transit datetime.
+
+    TRANSIT LOCATION (optional):
+        transit_latitude: Location latitude. Defaults to birth latitude.
+        transit_longitude: Location longitude. Defaults to birth longitude.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_transit_wheel
+
+    chart_data = await calculate_transit_chart_by_id(
+        person_id=person_id,
+        transit_datetime=transit_datetime,
+        transit_latitude=transit_latitude,
+        transit_longitude=transit_longitude,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_transit_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": ["wheel"],
+    }
+
+
+# ── render_synastry_chart ─────────────────────────────────────────────
+
+@mcp.tool()
+async def render_synastry_chart(
+    # ── Two people's birth data (same as calculate_synastry_chart) ──
+    person1_datetime: str,
+    person1_latitude: float,
+    person1_longitude: float,
+    person2_datetime: str,
+    person2_latitude: float,
+    person2_longitude: float,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 800,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a synastry (relationship) chart with two side-by-side wheels.
+
+    Calculates both natal charts and renders them side by side with
+    interaspect lines between the two charts.
+
+    PERSON 1 (required):
+        person1_datetime: ISO 8601 birth datetime with timezone.
+        person1_latitude: Birth latitude in decimal degrees.
+        person1_longitude: Birth longitude in decimal degrees.
+
+    PERSON 2 (required):
+        person2_datetime: ISO 8601 birth datetime with timezone.
+        person2_latitude: Birth latitude in decimal degrees.
+        person2_longitude: Birth longitude in decimal degrees.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+        top_n_aspects: Limit interaspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_synastry_wheel
+
+    chart_data = await calculate_synastry_chart(
+        person1_datetime=person1_datetime,
+        person1_latitude=person1_latitude,
+        person1_longitude=person1_longitude,
+        person2_datetime=person2_datetime,
+        person2_latitude=person2_latitude,
+        person2_longitude=person2_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_synastry_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": ["wheel"],
+    }
+
+
+# ── render_synastry_chart_by_id ───────────────────────────────────────
+
+@mcp.tool()
+async def render_synastry_chart_by_id(
+    # ── Two person IDs ──────────────────────────────────────────────
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    top_n_aspects: int | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 800,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a synastry chart for two people from the database.
+
+    Looks up both persons by ID or nickname, calculates their synastry,
+    and renders side-by-side natal wheels with interaspect lines.
+
+    PERSON LOOKUP (required):
+        person1_id: ID or nickname of person 1 in the persons database.
+        person2_id: ID or nickname of person 2 in the persons database.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+        top_n_aspects: Limit interaspects to the N tightest by orb.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_synastry_wheel
+
+    chart_data = await calculate_synastry_chart_by_id(
+        person1_id=person1_id,
+        person2_id=person2_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+        top_n_aspects=top_n_aspects,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_synastry_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": ["wheel"],
+    }
+
+
+# ── render_composite_chart ────────────────────────────────────────────
+
+@mcp.tool()
+async def render_composite_chart(
+    # ── Two people's birth data (same as calculate_composite_chart) ─
+    person1_datetime: str,
+    person1_latitude: float,
+    person1_longitude: float,
+    person2_datetime: str,
+    person2_latitude: float,
+    person2_longitude: float,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a composite chart (midpoint method) as a single wheel.
+
+    Calculates the composite chart from two people's birth data and renders
+    it as a standard natal-style wheel representing the relationship.
+
+    PERSON 1 (required):
+        person1_datetime: ISO 8601 birth datetime with timezone.
+        person1_latitude: Birth latitude in decimal degrees.
+        person1_longitude: Birth longitude in decimal degrees.
+
+    PERSON 2 (required):
+        person2_datetime: ISO 8601 birth datetime with timezone.
+        person2_latitude: Birth latitude in decimal degrees.
+        person2_longitude: Birth longitude in decimal degrees.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_composite_chart(
+        person1_datetime=person1_datetime,
+        person1_latitude=person1_latitude,
+        person1_longitude=person1_longitude,
+        person2_datetime=person2_datetime,
+        person2_latitude=person2_latitude,
+        person2_longitude=person2_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": _included_list(table_position, include_planets, include_houses),
+    }
+
+
+# ── render_composite_chart_by_id ──────────────────────────────────────
+
+@mcp.tool()
+async def render_composite_chart_by_id(
+    # ── Two person IDs ──────────────────────────────────────────────
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a composite chart for two people from the database.
+
+    Looks up both persons by ID, calculates the composite chart, and
+    renders it as a single natal-style wheel.
+
+    PERSON LOOKUP (required):
+        person1_id: ID or nickname of person 1 in the persons database.
+        person2_id: ID or nickname of person 2 in the persons database.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_composite_chart_by_id(
+        person1_id=person1_id,
+        person2_id=person2_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": _included_list(table_position, include_planets, include_houses),
+    }
+
+
+# ── render_davison_chart ──────────────────────────────────────────────
+
+@mcp.tool()
+async def render_davison_chart(
+    # ── Two people's birth data (same as calculate_davison_chart) ───
+    person1_datetime: str,
+    person1_latitude: float,
+    person1_longitude: float,
+    person2_datetime: str,
+    person2_latitude: float,
+    person2_longitude: float,
+    elevation: float = 0.0,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a Davison chart (midpoint in time and space) as a single wheel.
+
+    Calculates the Davison chart from two people's birth data and renders
+    it as a standard natal-style wheel.
+
+    PERSON 1 (required):
+        person1_datetime: ISO 8601 birth datetime with timezone.
+        person1_latitude: Birth latitude in decimal degrees.
+        person1_longitude: Birth longitude in decimal degrees.
+
+    PERSON 2 (required):
+        person2_datetime: ISO 8601 birth datetime with timezone.
+        person2_latitude: Birth latitude in decimal degrees.
+        person2_longitude: Birth longitude in decimal degrees.
+
+    CHART OPTIONS:
+        elevation: Birth elevation in meters (default: 0).
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_davison_chart(
+        person1_datetime=person1_datetime,
+        person1_latitude=person1_latitude,
+        person1_longitude=person1_longitude,
+        person2_datetime=person2_datetime,
+        person2_latitude=person2_latitude,
+        person2_longitude=person2_longitude,
+        elevation=elevation,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": _included_list(table_position, include_planets, include_houses),
+    }
+
+
+# ── render_davison_chart_by_id ────────────────────────────────────────
+
+@mcp.tool()
+async def render_davison_chart_by_id(
+    # ── Two person IDs ──────────────────────────────────────────────
+    person1_id: str,
+    person2_id: str,
+    house_system: str = "placidus",
+    orb_limits: dict[str, float] | None = None,
+    # ── Rendering options ───────────────────────────────────────────
+    style: str = "modern",
+    color_mode: str = "color",
+    size: int = 600,
+    table_position: str = "none",
+    include_planets: bool = False,
+    include_houses: bool = False,
+    title: str | None = None,
+) -> dict[str, Any]:
+    """Render a Davison chart for two people from the database.
+
+    Looks up both persons by ID, calculates the Davison chart, and
+    renders it as a single natal-style wheel.
+
+    PERSON LOOKUP (required):
+        person1_id: ID or nickname of person 1 in the persons database.
+        person2_id: ID or nickname of person 2 in the persons database.
+
+    CHART OPTIONS:
+        house_system: "placidus" (default), "equal", or "whole_sign".
+        orb_limits: Per-aspect orb overrides.
+
+    RENDERING OPTIONS:
+        style: {_RENDER_STYLE_HELP}
+        color_mode: {_RENDER_COLOR_HELP}
+        size: {_RENDER_SIZE_HELP}
+        table_position: "none" (wheel only), "below" (portrait), or "right" (landscape).
+        include_planets: {_RENDER_PLANETS_HELP}
+        include_houses: {_RENDER_HOUSES_HELP}
+        title: {_RENDER_TITLE_HELP}
+
+    Returns:
+        Dict with "svg", "format", "width", "height", "included".
+    """
+    from .chart_renderer import render_natal_wheel
+
+    chart_data = await calculate_davison_chart_by_id(
+        person1_id=person1_id,
+        person2_id=person2_id,
+        house_system=house_system,
+        orb_limits=orb_limits,
+    )
+    if "error" in chart_data:
+        return chart_data
+
+    svg = render_natal_wheel(
+        chart_data,
+        style=style,
+        color_mode=color_mode,
+        size=size,
+        table_position=table_position,
+        include_planets=include_planets,
+        include_houses=include_houses,
+        title=title,
+    )
+    return {
+        "svg": svg,
+        "format": "svg",
+        "width": size,
+        "height": size,
+        "included": _included_list(table_position, include_planets, include_houses),
+    }
+
+
+# ── Helper ────────────────────────────────────────────────────────────
+
+def _included_list(table_position: str, planets: bool, houses: bool) -> list[str]:
+    result = ["wheel"]
+    if table_position in ("below", "right"):
+        if planets:
+            result.append("planet_table")
+        if houses:
+            result.append("house_table")
+    return result