"""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_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 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) -> str: return (f'') def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "") -> str: c = f' class="{cls}"' if cls else "" return (f'{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) -> str: """Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów).""" 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'') # 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, "var(--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, "var(--line)", 1.0)) gx, gy = _pt(b + 15.0, asc, R_SIGN) parts.append(_text(gx, gy, sg.get("glyph") or "", size=17, fill=_ELEMENT_COLOR[i % 4], cls="glyph")) # 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, "var(--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="var(--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, "var(--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="var(--accent)")) parts.append(_text(lbx, lby, lb, size=10, fill="var(--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, "var(--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, "var(--line)", 0.7, 0.9)) gx, gy = _pt(draw_lon, asc, R_PLANET) parts.append(_text(gx, gy, obj.get("glyph") or "", size=16, fill="var(--ink)", cls="glyph")) # 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="#ff9b6a" if retro else "var(--muted)")) body = "".join(parts) return (f'{body}')