"""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}"') # jako dziecko daje natywny dymek po najechaniu — bez JS-a. if title: return f'<line {attrs}><title>{escape(title)}' return f'' # 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"{escape(title)}" if title else "" # dymek po najechaniu return (f'{t}{escape(s)}') 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'') parts.append(f'') parts.append(f'') # ── 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'{body}') # ── 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'') 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'') 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'{body}') # ── 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'') _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'') parts.append(f'') # 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'{body}') _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'{escape(tip)}') 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'{body}')