Files
astrololo/services/presentation/app/chartwheel.py
T
gitea 1d7f3e2136
Testy / Build obrazu silnika B (swisseph) (push) Waiting to run
Testy / Kontrola składni wszystkich warstw (push) Waiting to run
build / build (push) Successful in 1m26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m10s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Has started running
feat(prezentacja): szkielet kosmogramu — koło horoskopowe SVG (PRE-12)
Pierwszy krok rysowania wykresu (PRE-12): SZKIELET koła — pierścień znaków,
podział na domy i osie. Planety i linie aspektów w kolejnych iteracjach.

Rysowane po stronie serwera jako czysty string SVG (bez zależności zewnętrznych
— spójne z CSP). Ciemny motyw, spójny z paletą aplikacji (kolory z --line,
--accent, --muted; glify znaków barwione wg żywiołu w stonowanych kolorach
czytelnych na ciemnym tle).

Geometria wg konwencji astrologicznej: Ascendent po LEWEJ, długość ekliptyczna
rośnie przeciwnie do ruchu wskazówek zegara. Punkt λ → kąt φ = 180° − (λ − Asc);
w SVG y rośnie w dół, co formuła uwzględnia. Zakotwiczenie sprawdzone testami
liczbowo: Asc po lewej, Dsc po prawej, oś pozioma; Asc+90° (II dom) na dole.

Elementy: dwa okręgi + piasta, podziałki co 5°/10°, granice znaków co 30° z
glifem w środku sektora, szprychy domów od pasa do piasty, numery domów w środku
każdego domu, osie Asc–Dsc i MC–IC wyróżnione akcentem z etykietami AC/DC/MC/IC.

Dane bierzemy WYŁĄCZNIE z wyniku /chart/positions — prezentacja nic nie liczy:
- `sign_glyphs` (pierścień 12 znaków) i glify — z LOG-22,
- `angles` z długością `decimal` — już były,
- `cusps` z `decimal` — DOŁOŻONE w tym PR (jedna linia w build_chart). Bez tego
  domy dało się narysować tylko dla whole sign; z długością cuspu działa dla
  KAŻDEGO systemu. Zweryfikowane na porphyry: cuspy poza wielokrotnościami 30°,
  szprychy odchodzą od granic znaków.

Degradacja: silnik bez osi/domów albo starszy wynik bez `decimal` w cuspach →
brak koła (pusty string), nie wyjątek.

Testy: 8 (poprawność XML, 12 glifów, 4 osie, numery domów, Asc po lewej liczbowo,
CCW, degradacja). Całość: prezentacja 33 passed, logika 265 / 1 skipped. Render
potwierdzony wizualnie na horoskopie referencyjnym (AC=Leo po lewej, MC=Aries
u góry, domy CCW).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:32:47 +02:00

115 lines
5.4 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 — szkielet koła horoskopowego jako SVG (PRE-12).
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
string SVG wstawiany do strony. Ten krok to SZKIELET — pierścień znaków, podział
na domy i osie. Planety i linie aspektów dojdą w kolejnych iteracjach.
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 440×440, środek 220,220) ─────────────────────────
_CX = _CY = 220.0
R_OUT = 210.0 # zewnętrzny okrąg
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)
# 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'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "") -> str:
c = f' class="{cls}"' if cls else ""
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}>{escape(s)}</text>')
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'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="#12132a" stroke="var(--line)" stroke-width="1.5"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="var(--line)" stroke-width="1"/>')
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="var(--line)" stroke-width="1"/>')
# 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: 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, "var(--accent)", 1.6, 0.9))
# etykiety tuż przy krawędzi pasa
lax, lay = _pt(lon_a, asc, R_ZOD - 12)
lbx, lby = _pt(lon_a + 180.0, asc, R_ZOD - 12)
parts.append(_text(lax, lay, la, size=10, fill="var(--accent)"))
parts.append(_text(lbx, lby, lb, size=10, fill="var(--accent)"))
body = "".join(parts)
return (f'<svg class="wheel" viewBox="0 0 440 440" role="img" '
f'aria-label="Kosmogram — szkielet koła horoskopowego" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')