"""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", } 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 = "") -> str: c = f' class="{cls}"' if cls else "" f = f' font-family="{font}"' if font else "" t = f"{escape(title)}" if title else "" # dymek po najechaniu return (f'{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}')