瀏覽代碼

fix: chart rendering improvements - title block, angle glyphs, ticks, band fill

- Title block: grouped <g> with hanging baseline, proper line spacing
- Angle glyphs: single Astronomicon glyph per axis, larger (22px), tangentially offset
- Angle axis lines: extended beyond outer rim to r_zod_out+24
- Tick marks: 30°/10°=10px, 5°=7px, 1°=3px, all outward from zodiac edge
- Zodiac band fill: proper ring segment paths (outer arc → line → inner arc → close)
- Zodiac glyphs: centered in band at 0.855
- Wheel: slightly smaller (margin 20px) to accommodate labels
Lukas Goldschmidt 1 月之前
父節點
當前提交
c7ee0ea269
共有 1 個文件被更改,包括 96 次插入87 次删除
  1. 96 87
      src/astro_mcp/chart_renderer.py

+ 96 - 87
src/astro_mcp/chart_renderer.py

@@ -246,7 +246,7 @@ _R_TICK_OUTER   = 0.980   # outer tick root (5° ticks)
 _R_TICK_INNER_5 = 0.955   # 5° tick inner end
 _R_TICK_INNER_1 = 0.967   # 1° tick inner end  (not drawn at this res)
 _R_ZODIAC_OUTER = 0.940   # outer zodiac band edge (stroke circle)
-_R_ZODIAC_GLYPH = 0.895   # midpoint for sign glyph (~halfway in zodiac band)
+_R_ZODIAC_GLYPH = 0.855   # center of zodiac band ((0.940+0.770)/2)
 _R_ZODIAC_INNER = 0.770   # inner zodiac band edge (stroke circle)
 _R_HOUSE_NUM    = 0.700   # house number label position
 _R_CUSP_INNER   = 0.350   # house cusp lines extend inward to here
@@ -320,7 +320,7 @@ def render_natal_wheel(
     # Wheel is always perfectly square, centered in the first 'size x size' area
     wheel_cx = size / 2
     wheel_cy = size / 2
-    outer_r  = size / 2 - 4   # tiny margin so stroke doesn't clip
+    outer_r  = size / 2 - 20   # margin for angle labels and axis lines
 
     # ── ASC longitude for rotation ───────────────────────────────────
     asc_lon = angles.get("ascendant", {}).get("absolute_lon", 0.0)
@@ -360,11 +360,18 @@ def render_natal_wheel(
                 element = astrology.SIGN_ELEMENTS.get(sign_name, "")
                 seg_color = theme.get(f"zodiac_{element}", theme["ring_fill"])
 
-            # Pie slice from center to outer zodiac edge
-            # Zodiac goes CCW (decreasing angle), so sweep=0
-            sx, sy = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_start)
-            arc = _arc_path(wheel_cx, wheel_cy, r_zod_out, a_start, a_end, sweep=0)
-            path_d = f"M {wheel_cx:.2f},{wheel_cy:.2f} L {sx:.2f},{sy:.2f} {arc} Z"
+            # Zodiac band segment fill (between outer and inner zodiac edges)
+            # Path: outer arc (CCW) → radial line → inner arc (CW) → radial line → close
+            sx_out, sy_out = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_start)
+            ex_out, ey_out = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, a_end)
+            sx_in, sy_in = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a_start)
+            ex_in, ey_in = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a_end)
+            path_d = (
+                f"M {sx_out:.2f},{sy_out:.2f} "
+                f"A {r_zod_out:.2f},{r_zod_out:.2f} 0 0,0 {ex_out:.2f},{ey_out:.2f} "
+                f"L {ex_in:.2f},{ey_in:.2f} "
+                f"A {r_zod_in:.2f},{r_zod_in:.2f} 0 0,1 {sx_in:.2f},{sy_in:.2f} Z"
+            )
             dwg.add(dwg.path(d=path_d, fill=seg_color, stroke="none"))
 
         # Sign glyph at midpoint of segment
@@ -392,15 +399,28 @@ def render_natal_wheel(
 
     # ── Degree tick marks on outer zodiac ring ───────────────────────
     r_tick_out = _r(outer_r, _R_TICK_OUTER)
-    for deg_tick in range(0, 360, 5):
+    for deg_tick in range(0, 360):
         t_angle  = _svg_angle(float(deg_tick), asc_lon)
         is_sign  = deg_tick % 30 == 0
-        is_major = deg_tick % 10 == 0
-        t_inner  = r_zod_out - (10 if is_sign else (4 if is_major else 2))
-        t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, t_inner, t_angle)
-        t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, r_tick_out, t_angle)
-        stroke   = theme["tick_major"] if is_sign or is_major else theme["tick_minor"]
-        width    = 1.5 if is_sign else (1.0 if is_major else 0.5)
+        is_10    = deg_tick % 10 == 0
+        is_5     = deg_tick % 5 == 0
+
+        if is_sign or is_10:
+            tick_len = 10
+            stroke   = theme["tick_major"]
+            width    = 1.5
+        elif is_5:
+            tick_len = 7
+            stroke   = theme["tick_major"]
+            width    = 1.0
+        else:
+            tick_len = 3
+            stroke   = theme["tick_minor"]
+            width    = 0.8
+
+        # Ticks extend OUTWARD from the zodiac outer edge
+        t1x, t1y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out, t_angle)
+        t2x, t2y = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out + tick_len, t_angle)
         dwg.add(dwg.line(start=(t1x, t1y), end=(t2x, t2y),
                           stroke=stroke, stroke_width=width))
 
@@ -444,13 +464,14 @@ def render_natal_wheel(
             class_="lbl", font_size="11px", fill=theme["degree_text"],
         ))
 
-    # ── Angle axis lines (full diameter lines for ASC-DSC and MC-IC) ─
+    # ── Angle axis lines (ASC-DSC and MC-IC) ─────────────────────────
+    # Extend from center circle through zodiac band and beyond outer rim
     for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
         lon = angles.get(key, {}).get("absolute_lon")
         if lon is not None:
             a = _svg_angle(lon, asc_lon)
             ax1, ay1 = _polar_to_cartesian(wheel_cx, wheel_cy, _r(outer_r, _R_CENTER), a)
-            ax2, ay2 = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_in, a)
+            ax2, ay2 = _polar_to_cartesian(wheel_cx, wheel_cy, r_zod_out + 24, a)
             dwg.add(dwg.line(
                 start=(ax1, ay1), end=(ax2, ay2),
                 stroke=theme["axis_line"], stroke_width=1.5,
@@ -499,19 +520,14 @@ def render_natal_wheel(
         dwg.add(dwg.line(start=(t1x, t1y), end=(t2x, t2y),
                           stroke=theme["ring_stroke"], stroke_width=1.5))
 
-    # ── Angle labels just outside the zodiac outer ring ───────────────
-    # Use Astronomicon special chars for angle points
+    # ── Angle glyphs at the end of extended axis lines ────────────────
     angle_chars = {
-        "ascendant":  glyph("ascendant"),   # 'c' in Astronomicon
-        "descendant": glyph("descendant"),   # 'f'
-        "midheaven":  glyph("midheaven"),    # 'd'
-        "imum_coeli": glyph("imum_coeli"),   # 'e'
-    }
-    angle_text = {
-        "ascendant": "ASC", "descendant": "DSC",
-        "midheaven": "MC",  "imum_coeli": "IC",
+        "ascendant":  glyph("ascendant"),
+        "descendant": glyph("descendant"),
+        "midheaven":  glyph("midheaven"),
+        "imum_coeli": glyph("imum_coeli"),
     }
-    r_angle_lbl = outer_r + 8
+    r_angle_lbl = r_zod_out + 22
     for key in ("ascendant", "midheaven", "descendant", "imum_coeli"):
         lon = angles.get(key, {}).get("absolute_lon")
         if lon is None:
@@ -519,24 +535,21 @@ def render_natal_wheel(
         a   = _svg_angle(lon, asc_lon)
         nx, ny = _polar_to_cartesian(wheel_cx, wheel_cy, r_angle_lbl, a)
 
-        deg_within = angles[key].get("degree_within_sign", 0)
-        sign_abbr  = angles[key].get("sign_abbreviation", "")
-        deg_str    = f" {int(deg_within)}\u00b0 {sign_abbr}"
-
-        # Anchor based on angle quadrant
-        if 135 < a < 315:
-            anchor = "end"
-        elif 45 < a < 135 or 225 < a < 315:
-            anchor = "middle"
-        else:
-            anchor = "start"
+        g_char = angle_chars.get(key, "")
+        # Offset glyph tangentially to avoid overlapping with the axis line
+        # ASC up, DSC down, MC right, IC left
+        offset = 8
+        rad = math.radians(a)
+        # Tangential direction: rotate 90° CW from radial
+        tx = math.cos(rad) * offset
+        ty = math.sin(rad) * offset
+        nx += tx
+        ny += ty
 
-        # Use Astronomicon char for the angle point + text label
         dwg.add(dwg.text(
-            angle_chars.get(key, "") + " " + angle_text[key] + deg_str,
-            insert=(nx, ny),
-            text_anchor=anchor, dominant_baseline="central",
-            class_="zf", font_size="10px", fill=theme["angle_text"],
+            g_char, insert=(nx, ny),
+            text_anchor="middle", dominant_baseline="central",
+            class_="zf", font_size="22px", fill=theme["angle_text"],
         ))
 
     # ── Title block — top-left corner ────────────────────────────────
@@ -577,35 +590,26 @@ def _render_title_corner(
     title: str | None,
     subtitle: str | None,
 ) -> None:
-    """Render title block in the top-left corner."""
+    """Render title block in the top-left corner using a grouped layout."""
     inp    = chart_data.get("input", {})
     angles = chart_data.get("angles", {})
 
-    y = 10
     if title is None:
         chart_type = chart_data.get("chart_type", "natal").capitalize()
         name = inp.get("name", "")
         title = f"{name}" if name else f"{chart_type} Chart"
 
-    # Line 1: Name (bold, larger)
-    dwg.add(dwg.text(
-        title, insert=(8, y),
-        text_anchor="start", dominant_baseline="auto",
-        class_="lbl", font_size="13px", fill=theme["title_text"],
-        font_weight="bold",
-    ))
-    y += 14
+    # Build title lines as (text, font_size, fill, bold) tuples
+    lines: list[tuple[str, str, str, bool]] = []
 
-    # Line 2: "Natal Chart" label
-    if chart_data.get("chart_type", "natal") == "natal":
-        dwg.add(dwg.text(
-            "Natal Chart", insert=(8, y),
-            text_anchor="start", dominant_baseline="auto",
-            class_="lbl", font_size="8px", fill=theme["data_text"],
-        ))
-        y += 12
+    # Small "Natal Chart for:" label
+    if chart_data.get("chart_type", "natal") == "natal" and title:
+        lines.append(("Natal Chart for:", "8px", theme["footer_text"], False))
+
+    # Name (bold, larger)
+    lines.append((title, "14px", theme["title_text"], True))
 
-    # Line 3: Birth datetime (formatted)
+    # Birth datetime
     bdt = inp.get("birth_datetime", "")
     if bdt:
         try:
@@ -616,44 +620,49 @@ def _render_title_corner(
                 tz_name = dt.strftime("%Z")
                 if tz_name and tz_name != "UTC":
                     dt_str += f" {tz_name}"
-            dwg.add(dwg.text(
-                dt_str, insert=(8, y),
-                text_anchor="start", dominant_baseline="auto",
-                class_="lbl", font_size="8px", fill=theme["data_text"],
-            ))
-            y += 11
+            lines.append((dt_str, "8px", theme["data_text"], False))
         except Exception:
             pass
 
-    # Line 4: Birthplace
+    # Birthplace
     bp = inp.get("birthplace", "")
     if bp:
-        dwg.add(dwg.text(
-            bp, insert=(8, y),
-            text_anchor="start", dominant_baseline="auto",
-            class_="lbl", font_size="8px", fill=theme["data_text"],
-        ))
-        y += 11
+        lines.append((bp, "8px", theme["data_text"], False))
 
-    # Line 5: Lat/Lon
+    # Lat/Lon
     lat = inp.get("latitude")
     lon = inp.get("longitude")
     if lat is not None and lon is not None:
         lat_dir = "N" if lat >= 0 else "S"
         lon_dir = "E" if lon >= 0 else "W"
-        dwg.add(dwg.text(
-            f"{abs(lat):.4f}\u00b0{lat_dir}  {abs(lon):.4f}\u00b0{lon_dir}",
-            insert=(8, y),
-            text_anchor="start", dominant_baseline="auto",
-            class_="lbl", font_size="7.5px", fill=theme["data_text"],
-        ))
+        lines.append((f"{abs(lat):.4f}°{lat_dir}  {abs(lon):.4f}°{lon_dir}", "7.5px", theme["data_text"], False))
 
     if subtitle:
-        dwg.add(dwg.text(
-            subtitle, insert=(7, 25),
-            text_anchor="start", dominant_baseline="auto",
-            class_="lbl", font_size="7.5px", fill=theme["data_text"],
-        ))
+        lines.append((subtitle, "7.5px", theme["data_text"], False))
+
+    # Render all lines inside a <g> group with consistent line spacing
+    x = 8
+    y_start = 12
+    line_gap = 4  # extra gap between lines in px
+
+    # Calculate positions: each line's y = previous y + previous font_size + line_gap
+    g = dwg.g(class_="title-block")
+    y = y_start
+    for i, (text, font_size, fill, bold) in enumerate(lines):
+        # Parse font_size to float for line height calculation
+        fs = float(font_size.replace("px", ""))
+        # Use hanging baseline so y is the top of the text
+        t = dwg.text(
+            text, insert=(x, y),
+            text_anchor="start", dominant_baseline="hanging",
+            class_="lbl", font_size=font_size, fill=fill,
+        )
+        if bold:
+            t.attribs["font-weight"] = "bold"
+        g.add(t)
+        y += fs + line_gap
+
+    dwg.add(g)
 
 
 # ── Planet rendering with collision avoidance ─────────────────────────