Files
astrololo/services/presentation/app/chartwheel.py
T
gitea 171deff2d1
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m36s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m35s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 48s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m36s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 31s
Testy / Kontrola składni wszystkich warstw (push) Successful in 17s
feat(prezentacja): linie aspektów na kosmogramie (PRE-12, etap 3)
Trzeci etap rysowania koła: linie aspektów w środku. Domyka rdzeń PRE-12 —
kosmogram ma teraz znaki, domy, osie, obiekty i aspekty.

Linie łączą PRAWDZIWE pozycje obiektów (nie rozsunięte glify — aspekt dotyczy
tego, gdzie planeta faktycznie stoi) na okręgu piasty, w środku koła.

Kodowanie jak w klasycznych programach:
- KOLOR = charakter aspektu: niebieski harmonijny (trygon, sekstyl), czerwony
  napięty (kwadratura, opozycja), zielony koniunkcja. Dwa kolory na środku od
  razu mówią „gdzie łatwo, gdzie tarcie".
- GRUBOŚĆ i JASNOŚĆ = orb: im ciaśniej, tym mocniej. Poniżej 1° wyraźne
  pogrubienie (wprost z wymagania) — najściślejsze aspekty rzucają się w oczy,
  a szerokie ledwo majaczą, żeby nie robić ze środka plątaniny.

Rysowane jako pierwsze, pod resztą: obiekty siedzą na R_PLANET=150, daleko od
piasty (R_HUB=48), więc glify i linie się nie stykają, a osie i szprychy lądują
na wierzchu.

Motyw druku (PDF, PRE-24) dostaje własne, ciemniejsze kolory aspektów bez
zmiennych CSS — samodzielny konwerter SVG arkusza nie widzi.

Weryfikacja na horoskopie referencyjnym: 24 aspekty, 5 ciasnych (<1°) faktycznie
pogrubionych; kolory zgodne z typem (9 czerwonych napiętych, 10 niebieskich
harmonijnych, 5 zielonych koniunkcji); print bez zmiennych CSS. Sprawdzone
wizualnie — pełny aspektarian, czytelny.

Testy: 8 nowych (linie obecne, kolor wg typu, pogrubienie <1°, prawdziwa pozycja
nie rozsunięta, pominięcie aspektu do obiektu bez pozycji, brak aspektów, motyw
druku). Całość: prezentacja 138 passed. PRE-12 -> Zrobione.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:44:25 +02:00

304 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
# ── 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},
}
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) -> str:
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
# 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 = "") -> str:
c = f' class="{cls}"' if cls else ""
f = f' font-family="{font}"' if font else ""
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{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)
parts.append(_line(ax1, ay1, bx1, by1, color, w, op))
# 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"]))
# osie: AscDsc i MCIC 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)
parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
fill=T["ink"], cls="glyph", font=glyph_font))
# 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"]))
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>')