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
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>
623 lines
31 KiB
Python
623 lines
31 KiB
Python
"""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: 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")):
|
||
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>')
|