diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py
index 68f1611..41734a8 100644
--- a/services/presentation/app/chartwheel.py
+++ b/services/presentation/app/chartwheel.py
@@ -35,6 +35,7 @@ R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa)
R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie
R_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
R_PLANET = 150.0 # promień glifów obiektów
+R_LOT = 116.0 # promień glifów Lotów — pod pierścieniem planet (to nie ciała)
R_DEG = 130.0 # promień podpisu stopnia (pod glifem)
R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
@@ -82,6 +83,14 @@ _THEMES = {
}
+# Polskie nazwy aspektów — tylko do dymków; obliczenia trzymają nazwy angielskie.
+_ASPECT_PL = {
+ "conjunction": "koniunkcja", "sextile": "sekstyl", "square": "kwadratura",
+ "trine": "trygon", "opposition": "opozycja", "semisextile": "półsekstyl",
+ "semisquare": "półkwadratura", "quincunx": "kwinkunks",
+}
+
+
def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
"""Grubość i przezroczystość linii aspektu wg orbu.
@@ -102,9 +111,13 @@ def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
- opacity: float = 1.0) -> str:
- return (f'')
+ opacity: float = 1.0, title: str = "") -> str:
+ attrs = (f'x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
+ f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"')
+ #
jako dziecko daje natywny dymek po najechaniu — bez JS-a.
+ if title:
+ return f'{escape(title)}'
+ return f''
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
@@ -116,11 +129,12 @@ GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
- font: str = "") -> str:
+ font: str = "", title: str = "") -> str:
c = f' class="{cls}"' if cls else ""
f = f' font-family="{font}"' if font else ""
+ t = f"{escape(title)}" if title else "" # dymek po najechaniu
return (f'{escape(s)}')
+ f' text-anchor="middle" dominant-baseline="central"{c}{f}>{t}{escape(s)}')
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
@@ -222,7 +236,9 @@ def render(chart: dict, theme: str = "screen") -> str:
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
ax1, ay1 = _pt(la, asc, R_HUB)
bx1, by1 = _pt(lb, asc, R_HUB)
- parts.append(_line(ax1, ay1, bx1, by1, color, w, op))
+ tip = (f'{asp.get("obj1")} {_ASPECT_PL.get(asp.get("aspect"), asp.get("aspect"))} '
+ f'{asp.get("obj2")} · orb {float(asp.get("orb", 0.0)):.2f}°')
+ parts.append(_line(ax1, ay1, bx1, by1, color, w, op, title=tip))
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
for deg in range(0, 360, 5):
@@ -253,6 +269,13 @@ def render(chart: dict, theme: str = "screen") -> str:
mid = lon + span / 2.0
hx, hy = _pt(mid, asc, R_HNUM)
parts.append(_text(hx, hy, str(c["house"]), size=11, fill=T["muted"]))
+ # Stopień cuspu tuż przy szprysze, na pasie. W whole sign wszystkie cuspy
+ # są na 0° znaku — wtedy pomijamy, żeby nie kłaść dwunastu zbędnych zer;
+ # w systemach kwadratowych ta liczba realnie coś mówi.
+ deg_in_sign = lon % 30.0
+ if deg_in_sign >= 0.5:
+ cx1, cy1 = _pt(lon, asc, R_ZOD - 9)
+ parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
@@ -286,8 +309,15 @@ def render(chart: dict, theme: str = "screen") -> str:
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, asc, R_PLANET)
+ tip = obj.get("name") or ""
+ if obj.get("in_sign"):
+ tip += f" · {obj['in_sign']}"
+ if obj.get("house"):
+ tip += f" · dom {obj['house']}"
+ if retro:
+ tip += " · retrogradacja"
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
- fill=T["ink"], cls="glyph", font=glyph_font))
+ fill=T["ink"], cls="glyph", font=glyph_font, title=tip))
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
@@ -297,6 +327,27 @@ def render(chart: dict, theme: str = "screen") -> str:
parts.append(_text(dx, dy, label, size=8,
fill=T["retro"] if retro else T["muted"]))
+ # ── Lots (LOG-08): tylko te z glifem (Fortuna ⊗) ──────────────────
+ # Rysowane NA PIASTOWEJ stronie planet (R_LOT < R_PLANET) i akcentem, żeby
+ # od razu było widać, że to punkt wyliczony, nie ciało niebieskie. Bez
+ # rozsuwania — Lotów z glifem jest mało, a i tak siedzą pod pierścieniem planet.
+ for lot in (chart.get("lots") or []):
+ glyph = lot.get("glyph")
+ if not glyph or lot.get("longitude") is None:
+ continue
+ lon = float(lot["longitude"])
+ mx1, my1 = _pt(lon, asc, R_ZOD)
+ mx2, my2 = _pt(lon, asc, R_MARK)
+ parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
+ lx, ly = _pt(lon, asc, R_LOT)
+ tip = lot.get("name") or ""
+ if lot.get("in_sign"):
+ tip += f" · {lot['in_sign']}"
+ if lot.get("house"):
+ tip += f" · dom {lot['house']}"
+ parts.append(_text(lx, ly, glyph, size=13, fill=T["accent"],
+ cls="glyph", font=glyph_font, title=tip))
+
body = "".join(parts)
return (f'