adce568729
Ostatni etap rysowania kosmogramu: wizualizacje pochodne z aspektów pozazodiakalnych (LOG-07). Do tej pory paralele i antyscja były tylko w tabelach — teraz widać je na rzut oka. WYKRES DEKLINACJI (`render_declination`): - Pionowa skala deklinacji z równikiem (0°) i zwrotnikami (±ε, kreskowane) — ε bierzemy z `obliquity` w wyniku, więc granica jest dokładna dla daty. - Obiekty na osi X ułożone wg POSORTOWANEJ deklinacji, więc paralele (ta sama wysokość) lądują obok siebie. Łączniki: paralela zielona (jak koniunkcja), kontrparalela czerwona (jak opozycja) — te same barwy co linie na kole, grubość wg orbu. Dymek z nazwą zjawiska i orbem. - Strefa poza zwrotnikami cieniowana; obiekt OOB (out-of-bounds) w kolorze wyróżnienia + „OOB" w dymku. Od razu widać ciała o skrajnej deklinacji. OŚ ANTYSCJI (`render_antiscia`): - Ekliptyka rozwinięta w poziomą oś ze znakami; pionowo zaznaczona oś przesileń (0° Raka/Koziorożca) — lustro antyscji — i oś równonocy (0° Barana/Wagi) dla kontrantyscji. Pary połączone łukiem (zielony antyscja / czerwony kontrantyscja), z dymkiem. Bez par oś i obiekty i tak coś mówią. Oba w obu motywach (screen + print), więc gotowe też do PDF-a. Pokazują się na /chart pod odpowiednimi tabelami LOG-07. Testy: +13 (etap 6). Prezentacja: 170. Domyka etapy kosmogramu (PRE-12): 1 szkielet, 2 obiekty, 3 aspekty, 4 dopracowanie, 5 aspektarian, 6 deklinacja/antyscja. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
607 lines
30 KiB
Python
607 lines
30 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
|
||
|
||
|
||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
||
phi = math.radians(180.0 - (lon - asc))
|
||
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") -> 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.
|
||
"""
|
||
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"])
|
||
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, asc, R_HUB)
|
||
bx1, by1 = _pt(lb, asc, 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, asc, R_ZOD)
|
||
x2, y2 = _pt(deg, asc, 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, asc, R_ZOD)
|
||
x2, y2 = _pt(b, asc, R_OUT)
|
||
parts.append(_line(x1, y1, x2, y2, T["line"], 1.0))
|
||
gx, gy = _pt(b + 15.0, asc, 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, asc, R_ZOD)
|
||
x2, y2 = _pt(lon, asc, 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, 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")):
|
||
lon_a = float(angles[a]["decimal"])
|
||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
||
bx, by = _pt(lon_a + 180.0, asc, 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, asc, R_AXIS_LABEL)
|
||
lbx, lby = _pt(lon_a + 180.0, asc, 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, asc, R_ZOD)
|
||
mx2, my2 = _pt(true_lon, asc, 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, asc, R_PLANET + 12)
|
||
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, 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, asc, 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, 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'<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>')
|