|
|
@@ -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 ─────────────────────────
|