Files
astrololo/services/presentation/app/chartwheel.py
T
gitea 4d1daab35a
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m28s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 18s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 10s
build / build (push) Successful in 26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m27s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 17s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
feat(domy): warianty od Barana i od MC + obrót kosmogramu — LOG-05 zamknięte
Ostatnie trzy pozycje z treści LOG-05, których wcześniej nie było:

- whole_sign_aries — znaki jako domy, ale dom I ZAWSZE na 0° Barana, niezależnie
  od Ascendentu (tradycja indyjska i część szkół hellenistycznych),
- equal_mc — równe domy zakotwiczone na MC: dom X zaczyna się dokładnie na
  południku, a nie ma go gdzieś w środku,
- obrót kosmogramu: Ascendent albo 0° Barana po lewej stronie koła. Zmienia
  WYŁĄCZNIE rysunek, żadna liczba nie jest przeliczana. Wariant „od Barana"
  unieruchamia koło względem zodiaku, więc dwa horoskopy da się porównać na oko.

Oba nowe systemy zgodne z wyrocznią co do zera od pierwszego uruchomienia.
Razem 13 systemów: 2 833 424 porównania w przemiale, zero przekroczeń.

PRZY OKAZJI — DWA BŁĘDY, KTÓRE SAM WPROWADZIŁEM I KTÓRYCH TESTY NIE WIDZIAŁY:
- linia zbierająca ostrzeżenia o fallbacku trafiła do handlera strony głównej
  zamiast do compile_pdf: odwoływała się do nieistniejącej zmiennej, czyli 500
  na stronie głównej, a do PDF-a ostrzeżenia nie docierały wcale,
- compile_build używał parametru formularza, którego nie miał w sygnaturze.

Oba przeszły przez komplet zielonych testów, bo żaden nie wywoływał POST-a —
testy prezentacji sprawdzały teksty w szablonach i w main.py. Doszły więc testy
uderzające w prawdziwe trasy (POST / i POST /compile ze stubowaną logiką), które
łapią tę klasę błędu.

Z tego samego powodu przepisane dwa testy PDF-a: greppowały z main.py dokładny
kształt wywołania render(chart, theme="print") i pękały przy dopisaniu argumentu,
mimo poprawnego zachowania. Teraz wołają trasę i sprawdzają, że KAŻDY z czterech
rysunków dostaje motyw druku.

LOG-05 i PRE-05 → Zrobione w docs/astrololo_wymagania.xlsx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:38:43 +02:00

623 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Kosmogram — koło horoskopowe jako SVG (PRE-12).
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
string SVG wstawiany do strony. Gotowe: pierścień znaków, podział na domy, osie
oraz OBIEKTY (glify, stopień w znaku, retrogradacja). Linie aspektów w kolejnym
kroku.
Geometria (konwencja astrologiczna):
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
ruchu wskazówek zegara — jak na niebie i w każdym programie.
* Punkt o długości λ trafia na kąt φ = 180° Asc). Zakotwiczenie:
λ=Asc → lewo, λ=Asc+90° → dół, λ=Asc+270° (≈MC) → góra. W SVG y rośnie w dół,
co ta formuła już uwzględnia (sin dodatni = w dół).
Dane bierzemy z wyniku /chart/positions (warstwa logiki): `angles` (z długością
`decimal`), `cusps` (z `decimal` — dołożone pod ten widok) i `sign_glyphs`
(pierścień 12 znaków z glifami — LOG-22). Prezentacja niczego nie liczy sama.
"""
from __future__ import annotations
import math
from html import escape
# ── promienie (viewBox 470×470, środek 235,235) ─────────────────────────
# Płótno jest większe od koła CELOWO: margines poza obwodem mieści etykiety osi
# (AC/DC/MC/IC). Wcześniej siedziały wewnątrz, w pierścieniu obiektów, i zasłaniały
# glify w ciasnych skupiskach — widać było na renderze.
_CX = _CY = 235.0
_VIEW = 470
R_OUT = 210.0 # zewnętrzny okrąg
R_AXIS_LABEL = 222.0 # etykiety osi — w marginesie, poza kołem
R_ZOD = 178.0 # wewnętrzna krawędź pasa znaków
R_SIGN = 194.0 # promień glifów znaków (środek pasa)
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
# Minimalny odstęp kątowy między glifami obiektów. O ciasnocie decyduje NIE glif,
# tylko podpis stopnia — leży bliżej środka (r=130), gdzie ten sam kąt to mniej
# pikseli: 8° daje tam ~18 px, a dwuznakowy podpis z ℞ zajmuje ~16 px. Przy 7°
# (pierwsze podejście) podpisy w skupiskach zlewały się — widać było na renderze.
# Bez rozsuwania w ogóle ciasne układy (u nas Merkury i Wenus 0,4°!) rysują się
# jeden na drugim.
MIN_SEP = 8.0
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
# ── motywy ───────────────────────────────────────────────────────────────
# „screen" korzysta ze zmiennych CSS strony, więc koło samo dostraja się do
# motywu aplikacji. „print" MUSI mieć konkretne kolory: SVG idzie wtedy do
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
# Kolor linii aspektu koduje jego CHARAKTER, jak w klasycznych programach:
# niebieski = harmonijny (trygon, sekstyl), czerwony = napięty (kwadratura,
# opozycja), zielony = koniunkcja (neutralna, wzmacnia). To pierwszy sygnał, jaki
# łapie oko — dwa kolory na środku koła mówią „gdzie łatwo, gdzie tarcie".
_ASPECT_COLORS_SCREEN = {
"conjunction": "#8fae7a", "sextile": "#6aa6c9", "trine": "#6aa6c9",
"square": "#cf6a6a", "opposition": "#cf6a6a",
"semisextile": "#6aa6c9", "semisquare": "#cf6a6a", "quincunx": "#cf6a6a",
}
_ASPECT_COLORS_PRINT = {
"conjunction": "#3f6b33", "sextile": "#1f5c80", "trine": "#1f5c80",
"square": "#a83232", "opposition": "#a83232",
"semisextile": "#1f5c80", "semisquare": "#a83232", "quincunx": "#a83232",
}
_THEMES = {
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
"elements": _ELEMENT_COLOR, "aspects": _ASPECT_COLORS_SCREEN},
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
"elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"],
"aspects": _ASPECT_COLORS_PRINT},
}
# 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",
}
# Glify aspektów (te same znaki co w warstwie logiki: engine/glyphs.py ASPECT).
# Trzymamy je lokalnie, bo prezentacja ma już u siebie kolory i polskie nazwy
# aspektów — dzięki temu aspektarian jest samowystarczalny i nie zależy od tego,
# czy pojedynczy rekord aspektu niesie glif.
_ASPECT_GLYPH = {
"conjunction": "☌", "opposition": "☍", "trine": "△", "square": "□",
"sextile": "⚹", "semisextile": "⚺", "quincunx": "⚻", "semisquare": "∠",
}
def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
"""Grubość i przezroczystość linii aspektu wg orbu.
Im ciaśniejszy aspekt, tym mocniejsza linia — bo tym silniej działa. Poniżej
1° POGRUBIAMY wyraźnie (wprost z wymagania): najściślejsze aspekty mają się
rzucać w oczy. Szerokie, na granicy orbu, ledwo majaczą — żeby nie zamieniać
środka koła w plątaninę."""
allowed = allowed or 8.0
frac = max(0.0, 1.0 - orb / allowed) # 1 = dokładny, 0 = na krawędzi orbu
if orb < 1.0:
return 2.0, 0.95 # ciasny — pogrubiony
return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
# Co stoi po lewej stronie koła (LOG-05: „warianty 0°Aries / fixed Asc").
ASC_LEFT = "asc"
ARIES_LEFT = "aries"
ORIENTATIONS = ((ASC_LEFT, "Ascendent po lewej"), (ARIES_LEFT, "0° Barana po lewej"))
def _pt(lon: float, ref: float, r: float) -> tuple[float, float]:
"""Punkt na kole dla długości `lon`, przy `ref` po lewej stronie rysunku."""
phi = math.radians(180.0 - (lon - ref))
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
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}"')
# <title> jako dziecko daje natywny dymek po najechaniu — bez JS-a.
if title:
return f'<line {attrs}><title>{escape(title)}</title></line>'
return f'<line {attrs}/>'
# Stack fontów z glifami astrologicznymi. Na stronie dostarcza go klasa CSS
# `.glyph`, ale w wariancie do druku SVG jedzie do SAMODZIELNEGO konwertera, który
# naszego arkusza nie widzi — wtedy trzeba wpisać font wprost do elementu, inaczej
# rysunek wychodzi BEZ symboli (konwerter bierze domyślny font, a ten ich nie ma).
GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, "
"DejaVu Sans, sans-serif")
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "",
font: str = "", title: str = "", weight: str = "") -> str:
c = f' class="{cls}"' if cls else ""
f = f' font-family="{font}"' if font else ""
w = f' font-weight="{weight}"' if weight else ""
t = f"<title>{escape(title)}</title>" if title else "" # dymek po najechaniu
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}{f}{w}>{t}{escape(s)}</text>')
def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
"""Rozsuwa kąty tak, by sąsiedzi mieli co najmniej `min_sep`, zachowując
KOLEJNOŚĆ obiektów na kole i trzymając je blisko prawdziwych pozycji.
Metoda: relaksacja po okręgu — każdą zbyt ciasną parę rozpychamy po połowie
brakującego odstępu i powtarzamy, aż nic się nie rusza. Symetryczne pchnięcie
sprawia, że skupisko rozkłada się wokół swojego środka, zamiast dryfować
w jedną stronę.
Gdy obiektów jest tyle, że nie da się ich rozsunąć (n·min_sep ≥ 360°),
rozkładamy je równomiernie — lepiej równo niż w nieskończonej pętli.
"""
n = len(lons)
if n < 2:
return list(lons)
order = sorted(range(n), key=lambda i: lons[i] % 360.0)
a = [lons[i] % 360.0 for i in order]
if n * min_sep >= 360.0:
a = [(a[0] + i * 360.0 / n) % 360.0 for i in range(n)]
else:
# ROZWIJAMY kąty do osi monotonicznej. To nie kosmetyka: gdy liczyliśmy
# odstęp modulo 360, przesunięcie, które przerzuciło obiekt ZA sąsiada,
# dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że jest luzem,
# i kończył z dwoma glifami na sobie (Wenus lądowała na Księżycu).
# Na osi rozwiniętej ujemna luka zostaje ujemna i zawsze się ją wyłapie.
for i in range(1, n):
while a[i] < a[i - 1]:
a[i] += 360.0
for _ in range(passes):
moved = False
for i in range(n - 1):
gap = a[i + 1] - a[i]
if gap < min_sep - 1e-9:
push = (min_sep - gap) / 2.0
a[i] -= push
a[i + 1] += push
moved = True
closing = (a[0] + 360.0) - a[n - 1] # domknięcie okręgu
if closing < min_sep - 1e-9:
push = (min_sep - closing) / 2.0
a[n - 1] -= push
a[0] += push
moved = True
if not moved:
break
out = [0.0] * n
for slot, original in enumerate(order):
out[original] = a[slot] % 360.0
return out
def _delta(a: float, b: float) -> float:
"""Najmniejsza różnica kątów w [0,180]."""
return abs(((a - b + 180.0) % 360.0) - 180.0)
def render(chart: dict, theme: str = "screen", orientation: str = ASC_LEFT) -> str:
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).
theme='screen' — kolory ze zmiennych CSS aplikacji (dostraja się do motywu).
theme='print' — konkretne kolory na białym tle; konieczne, gdy SVG trafia
do samodzielnego konwertera (PDF), który zmiennych CSS nie rozwiąże.
orientation — co stoi po LEWEJ stronie koła (LOG-05, warianty rysunku):
'asc' — Ascendent (domyślne; tak rysuje większość szkół zachodnich,
dom I zawsze zaczyna się w tym samym miejscu rysunku),
'aries' — 0° Barana (koło nieruchome względem zodiaku, więc dwa horoskopy
da się porównywać „na oko"; osie wypadają za to gdzie indziej).
Zmienia się WYŁĄCZNIE obrót rysunku — żadna liczba nie jest przeliczana.
"""
T = _THEMES.get(theme, _THEMES["screen"])
# tylko dla druku — na stronie font podaje CSS (.glyph)
glyph_font = GLYPH_FONT if theme == "print" else ""
angles = chart.get("angles")
cusps = chart.get("cusps")
signs = chart.get("sign_glyphs")
if not (angles and cusps and signs) or "decimal" not in (cusps[0] if cusps else {}):
return ""
asc = float(angles["Asc"]["decimal"])
# Oś obrotu rysunku. Długości ekliptyczne zostają nietknięte — przesuwamy
# tylko punkt, który ląduje po lewej stronie koła.
ref = 0.0 if orientation == ARIES_LEFT else asc
parts: list[str] = []
# tło i okręgi
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="{T["disc"]}" stroke="{T["line"]}" stroke-width="1.5"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="{T["line"]}" stroke-width="1"/>')
# ── linie aspektów (LOG-06) w środku koła ──────────────────────────
# Łączą PRAWDZIWE pozycje obiektów (nie rozsunięte glify — aspekt dotyczy
# tego, gdzie planeta faktycznie stoi) na okręgu piasty. Rysowane jako
# PIERWSZE, pod resztą: obiekty siedzą na R_PLANET=150, daleko od piasty
# (R_HUB=48), więc się nie stykają, a osie i szprychy lądują na wierzchu.
pos_by_name = {p["name"]: float(p["decimal"])
for p in (chart.get("positions") or []) if p.get("decimal") is not None}
for asp in (chart.get("aspects") or []):
la = pos_by_name.get(asp.get("obj1"))
lb = pos_by_name.get(asp.get("obj2"))
if la is None or lb is None:
continue
color = T["aspects"].get(asp.get("aspect"), T["muted"])
w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
ax1, ay1 = _pt(la, ref, R_HUB)
bx1, by1 = _pt(lb, ref, R_HUB)
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):
r_in = R_TICK if deg % 10 else R_TICK - 4
x1, y1 = _pt(deg, ref, R_ZOD)
x2, y2 = _pt(deg, ref, r_in)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.6, 0.7))
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
for i, sg in enumerate(signs):
b = 30.0 * i
x1, y1 = _pt(b, ref, R_ZOD)
x2, y2 = _pt(b, ref, R_OUT)
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
gx, gy = _pt(b + 15.0, ref, R_SIGN)
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
fill=T["elements"][i % 4], cls="glyph", font=glyph_font))
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
n = len(cusps)
for i, c in enumerate(cusps):
lon = float(c["decimal"])
x1, y1 = _pt(lon, ref, R_ZOD)
x2, y2 = _pt(lon, ref, R_HUB)
parts.append(_line(x1, y1, x2, y2, T["line"], 0.8, 0.85))
nxt = float(cusps[(i + 1) % n]["decimal"])
span = (nxt - lon) % 360.0 or 360.0
mid = lon + span / 2.0
hx, hy = _pt(mid, ref, 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, ref, R_ZOD - 9)
parts.append(_text(cx1, cy1, str(int(deg_in_sign)), size=6.5, fill=T["muted"]))
# osie: AscDsc i MCIC przez całe koło, wyróżnione akcentem
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
lon_a = float(angles[a]["decimal"])
ax, ay = _pt(lon_a, ref, R_ZOD)
bx, by = _pt(lon_a + 180.0, ref, R_ZOD)
parts.append(_line(ax, ay, bx, by, T["accent"], 1.6, 0.9))
# etykiety POZA kołem — w środku kolidowały z glifami obiektów
lax, lay = _pt(lon_a, ref, R_AXIS_LABEL)
lbx, lby = _pt(lon_a + 180.0, ref, R_AXIS_LABEL)
parts.append(_text(lax, lay, la, size=10, fill=T["accent"]))
parts.append(_text(lbx, lby, lb, size=10, fill=T["accent"]))
# ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
# Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
# inaczej wykres kłamałby o tym, gdzie planeta faktycznie stoi. Gdy glif
# jest przesunięty, łączymy go z kreską cienką linią odniesienia.
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
true_lons = [float(p["decimal"]) for p in objects]
draw_lons = spread(true_lons)
for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
retro = obj.get("direction") == "Rx"
mx1, my1 = _pt(true_lon, ref, R_ZOD)
mx2, my2 = _pt(true_lon, ref, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["ink"], 1.0, 0.75))
if _delta(draw_lon, true_lon) > 0.4:
lx, ly = _pt(draw_lon, ref, R_PLANET + 12)
parts.append(_line(mx2, my2, lx, ly, T["line"], 0.7, 0.9))
gx, gy = _pt(draw_lon, ref, 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, title=tip))
# Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
# tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
# kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
dx, dy = _pt(draw_lon, ref, R_DEG)
label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
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, ref, R_ZOD)
mx2, my2 = _pt(lon, ref, R_MARK)
parts.append(_line(mx1, my1, mx2, my2, T["accent"], 1.0, 0.7))
lx, ly = _pt(lon, ref, 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'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
f'aria-label="Kosmogram — koło horoskopowe" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
# ── aspektarian (PRE-18) ──────────────────────────────────────────────────
ASP_CELL = 30.0 # bok komórki siatki aspektów
def render_aspectarian(chart: dict, theme: str = "screen") -> str:
"""Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG.
Klasyczny „schodkowy" układ: glify obiektów biegną po przekątnej, a każda
komórka POD nią to aspekt między obiektem ze swojego wiersza i ze swojej
kolumny — albo pusto, gdy pary nie łączy żaden aspekt (brak też coś mówi).
Koło pokazuje GEOMETRIĘ aspektów, aspektarian — ich TABELĘ na jeden rzut oka.
Kolory i pogrubienie ciasnych aspektów są SPÓJNE z liniami na kole (ten sam
motyw i te same barwy). Zwraca '' przy mniej niż dwóch obiektach.
theme='print' — konkretne kolory + font glifów wprost w elementach, bo SVG
trafia wtedy do samodzielnego konwertera (PDF), który arkusza CSS nie widzi.
"""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
n = len(objects)
if n < 2:
return ""
# aspekt po parze NAZW — kolejność obiektów w parze nie ma znaczenia
by_pair: dict = {}
for a in (chart.get("aspects") or []):
by_pair[frozenset((a.get("obj1"), a.get("obj2")))] = a
S = ASP_CELL
pad = 1.0
dim = n * S + 2 * pad
parts: list[str] = []
for i, oi in enumerate(objects):
# przekątna: glif obiektu na delikatnym tle — kręgosłup siatki
dx = pad + i * S
dy = pad + i * S
parts.append(
f'<rect x="{dx:.1f}" y="{dy:.1f}" width="{S}" height="{S}" '
f'fill="{T["accent"]}" opacity="0.14" stroke="{T["line"]}" stroke-width="0.6"/>')
dtip = oi.get("name") or ""
if oi.get("in_sign"):
dtip += f" · {oi['in_sign']}"
parts.append(_text(dx + S / 2, dy + S / 2, oi.get("glyph") or "", size=15,
fill=T["ink"], cls="glyph", font=glyph_font, title=dtip))
# komórki aspektów w tym wierszu: kolumny j < i (dolny-lewy trójkąt)
for j in range(i):
oj = objects[j]
cx = pad + j * S
cy = pad + i * S
parts.append(
f'<rect x="{cx:.1f}" y="{cy:.1f}" width="{S}" height="{S}" '
f'fill="none" stroke="{T["line"]}" stroke-width="0.6"/>')
asp = by_pair.get(frozenset((oi.get("name"), oj.get("name"))))
if not asp:
continue
name = asp.get("aspect")
glyph = _ASPECT_GLYPH.get(name)
if not glyph:
continue
orb = float(asp.get("orb", 0.0))
tight = orb < 1.0 # jak na kole: ciasny = wyróżniony
color = T["aspects"].get(name, T["muted"])
tip = (f'{oi.get("name")} {_ASPECT_PL.get(name, name)} {oj.get("name")} · '
f'orb {orb:.2f}°')
if asp.get("as"):
tip += " · " + ("aplikacyjny" if asp["as"] == "A" else "separacyjny")
parts.append(_text(cx + S / 2, cy + S / 2, glyph, size=17 if tight else 14,
fill=color, cls="glyph", font=glyph_font, title=tip,
weight="bold" if tight else ""))
body = "".join(parts)
return (f'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
# ── deklinacja i antyscja (LOG-07 → kosmogram etap 6) ─────────────────────
# Polskie nazwy zjawisk pozazodiakalnych — tylko do dymków.
_OOZ_PL = {
"parallel": "paralela", "contraparallel": "kontrparalela",
"antiscion": "antyscja", "contra_antiscion": "kontrantyscja",
}
def _dashed(x1: float, y1: float, x2: float, y2: float, stroke: str,
w: float = 0.8, opacity: float = 0.9, dash: str = "4 3") -> str:
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}" '
f'stroke-dasharray="{dash}"/>')
_DECL_W, _DECL_H = 470.0, 300.0
_DPX0, _DPX1 = 54.0, 452.0 # obszar rysunku w poziomie
_DPY0, _DPY1 = 30.0, 262.0 # góra (+Rmax) i dół (Rmax)
def render_declination(chart: dict, theme: str = "screen") -> str:
"""Wykres deklinacji (LOG-07, etap 6). Pionowa skala deklinacji: obiekty na
tej samej wysokości są w PARALELI (działa jak koniunkcja poza ekliptyką), na
lustrzanych względem równika — w KONTRPARALELI (jak opozycja). Zwrotniki (±ε)
to granica „poza zakresem" (OOB): ciało za nimi ma większą deklinację niż
kiedykolwiek Słońce. Kolory łączników jak na kole. '' przy < 2 obiektach."""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objs = [p for p in (chart.get("positions") or []) if p.get("declination") is not None]
if len(objs) < 2:
return ""
eps = float(chart.get("obliquity") or 23.4367)
maxabs = max(abs(float(p["declination"])) for p in objs)
Rmax = max(eps + 3.0, maxabs + 2.0) # skala mieści zwrotniki i OOB
def y_of(dec: float) -> float:
return _DPY0 + (Rmax - dec) / (2.0 * Rmax) * (_DPY1 - _DPY0)
parts: list[str] = []
# strefy OOB (za zwrotnikami) — delikatne tło
yN, yS = y_of(eps), y_of(-eps)
parts.append(f'<rect x="{_DPX0:.1f}" y="{_DPY0:.1f}" width="{_DPX1 - _DPX0:.1f}" '
f'height="{yN - _DPY0:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
parts.append(f'<rect x="{_DPX0:.1f}" y="{yS:.1f}" width="{_DPX1 - _DPX0:.1f}" '
f'height="{_DPY1 - yS:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
# równik (0°) i zwrotniki (±ε)
yeq = y_of(0.0)
parts.append(_line(_DPX0, yeq, _DPX1, yeq, T["line"], 1.0, 0.9))
parts.append(_text(_DPX0 - 8, yeq, "0°", size=8, fill=T["muted"]))
for d, lab in ((eps, f"+{eps:.0f}°"), (-eps, f"{eps:.0f}°")):
yy = y_of(d)
parts.append(_dashed(_DPX0, yy, _DPX1, yy, T["accent"], 0.8, 0.8))
parts.append(_text(_DPX0 - 8, yy, lab, size=8, fill=T["accent"]))
parts.append(_text((_DPX0 + _DPX1) / 2, 16,
"deklinacja — poza ±ε (zwrotniki) obiekt jest OOB", size=8, fill=T["muted"]))
# X równomiernie wg POSORTOWANEJ deklinacji — paralele lądują obok siebie
order = sorted(range(len(objs)), key=lambda i: float(objs[i]["declination"]))
n = len(order)
xy: dict = {}
for slot, idx in enumerate(order):
x = _DPX0 + (slot + 0.5) / n * (_DPX1 - _DPX0)
xy[objs[idx]["name"]] = (x, y_of(float(objs[idx]["declination"])))
# łączniki paralel/kontrparalel (zielony/czerwony — jak koniunkcja/opozycja)
for par in (chart.get("parallels") or []):
a = xy.get(par.get("obj1")); b = xy.get(par.get("obj2"))
if not a or not b:
continue
kind = par.get("type")
color = T["aspects"]["conjunction"] if kind == "parallel" else T["aspects"]["opposition"]
orb = float(par.get("orb", 0.0))
w, op = _aspect_pen(orb, par.get("allowed"))
tip = f'{par.get("obj1")} {_OOZ_PL.get(kind, kind)} {par.get("obj2")} · orb {orb:.2f}°'
parts.append(_line(a[0], a[1], b[0], b[1], color, w, op, title=tip))
# glify na wysokości ich deklinacji + podpis stopnia; OOB wyróżnione
for p in objs:
x, y = xy[p["name"]]
oob = bool(p.get("out_of_bounds"))
dec = float(p["declination"])
tip = f'{p.get("name")} · dekl. {dec:+.2f}°' + (" · OOB" if oob else "")
parts.append(_text(x, y, p.get("glyph") or "", size=15,
fill=T["retro"] if oob else T["ink"],
cls="glyph", font=glyph_font, title=tip))
parts.append(_text(x, y + 12, f"{dec:+.1f}", size=7,
fill=T["retro"] if oob else T["muted"]))
body = "".join(parts)
return (f'<svg class="declination" viewBox="0 0 {_DECL_W:.0f} {_DECL_H:.0f}" role="img" '
f'aria-label="Wykres deklinacji — paralele i out-of-bounds" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
_ANT_W, _ANT_H = 470.0, 150.0
_ANT_X0, _ANT_X1 = 22.0, 448.0
_ANT_Y = 100.0 # oś ekliptyki
def render_antiscia(chart: dict, theme: str = "screen") -> str:
"""Oś antyscji (LOG-07, etap 6). Ekliptyka rozwinięta w poziomą oś; pionowo
zaznaczona OŚ PRZESILEŃ (0° Raka / 0° Koziorożca) — względem niej odbija się
antyscja, a względem osi równonocy (0° Barana / Wagi) — kontrantyscja. Pary
połączone łukiem (zielony antyscja / czerwony kontrantyscja). '' przy < 2
obiektach."""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objs = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
if len(objs) < 2:
return ""
def x_of(lon: float) -> float:
return _ANT_X0 + (lon % 360.0) / 360.0 * (_ANT_X1 - _ANT_X0)
parts: list[str] = []
parts.append(_line(_ANT_X0, _ANT_Y, _ANT_X1, _ANT_Y, T["line"], 1.0, 0.9))
signs = chart.get("sign_glyphs") or []
for i in range(12):
xb = x_of(i * 30.0)
parts.append(_line(xb, _ANT_Y - 5, xb, _ANT_Y + 5, T["line"], 0.6, 0.6))
if i < len(signs):
parts.append(_text(x_of(i * 30.0 + 15.0), _ANT_Y + 17, signs[i].get("glyph") or "",
size=11, fill=T["muted"], cls="glyph", font=glyph_font))
# osie odbicia: przesileń (90/270) mocno, równonocy (0/180) delikatnie
for lon, col, op in ((90.0, T["accent"], 0.9), (270.0, T["accent"], 0.9),
(0.0, T["muted"], 0.5), (180.0, T["muted"], 0.5)):
xx = x_of(lon)
parts.append(_dashed(xx, _ANT_Y - 44, xx, _ANT_Y + 24, col, 0.9, op))
parts.append(_text((_ANT_X0 + _ANT_X1) / 2, 15,
"oś przesileń — antyscja · oś równonocy — kontrantyscja",
size=8, fill=T["muted"]))
lon_by = {p["name"]: float(p["decimal"]) for p in objs}
for row in (chart.get("antiscia") or []):
la = lon_by.get(row.get("obj1")); lb = lon_by.get(row.get("obj2"))
if la is None or lb is None:
continue
kind = row.get("type")
color = T["aspects"]["conjunction"] if kind == "antiscion" else T["aspects"]["opposition"]
xa, xb = x_of(la), x_of(lb)
orb = float(row.get("orb", 0.0))
tip = f'{row.get("obj1")} {_OOZ_PL.get(kind, kind)} {row.get("obj2")} · orb {orb:.2f}°'
midx = (xa + xb) / 2.0
top = _ANT_Y - 24 - abs(xb - xa) * 0.05
parts.append(f'<path d="M {xa:.1f} {_ANT_Y - 6:.1f} Q {midx:.1f} {top:.1f} '
f'{xb:.1f} {_ANT_Y - 6:.1f}" fill="none" stroke="{color}" '
f'stroke-width="1.2" opacity="0.9"><title>{escape(tip)}</title></path>')
for p in objs:
x = x_of(float(p["decimal"]))
parts.append(_text(x, _ANT_Y - 12, p.get("glyph") or "", size=12, fill=T["ink"],
cls="glyph", font=glyph_font,
title=f'{p.get("name")} · {p.get("in_sign", "")}'.strip(" ·")))
body = "".join(parts)
return (f'<svg class="antiscia" viewBox="0 0 {_ANT_W:.0f} {_ANT_H:.0f}" role="img" '
f'aria-label="Oś antyscji — odbicia względem przesileń i równonocy" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')