diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py index 6d84699..a5658a8 100644 --- a/services/logic/app/engine/chart.py +++ b/services/logic/app/engine/chart.py @@ -106,6 +106,7 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str result["cusps"] = [ {"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))], "in_sign": in_sign(norm360(c - off)), + "decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12) "sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])} for i, c in enumerate(cusp_list) ] diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py new file mode 100644 index 0000000..74a1b79 --- /dev/null +++ b/services/presentation/app/chartwheel.py @@ -0,0 +1,114 @@ +"""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'') + + +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 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 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'{body}') diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 3e13ffd..1d06ee4 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -92,6 +92,8 @@ def chart_compute( when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=house_system, stations=stations, zodiac=zodiac, tables=tables, ) + from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera + ctx["wheel_svg"] = chartwheel.render(ctx["result"]) except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index a089f20..c9c7d94 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -44,6 +44,12 @@ td.retro { color: #ff9b6a; font-weight: 600; } font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2", "DejaVu Sans", sans-serif; } td.glyph { color: var(--accent); white-space: nowrap; } +/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */ +.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; } +.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; } +.wheel-fig figcaption { margin-top: .4rem; } +/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */ +svg.wheel text.glyph { font-size: inherit; } form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; } .row { display: flex; gap: .5rem; } .row input[type=text] { flex: 1; } diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index deeccf0..040bf28 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -71,6 +71,13 @@ {% if moment %}· moment: {{ moment }}{% endif %} + {% if wheel_svg %} +
+ {{ wheel_svg | safe }} +
Kosmogram — szkielet (znaki, domy, osie). Planety i aspekty w kolejnych iteracjach.
+
+ {% endif %} + {% if result.angles %} diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py new file mode 100644 index 0000000..29587d8 --- /dev/null +++ b/services/presentation/tests/test_chartwheel.py @@ -0,0 +1,92 @@ +"""Szkielet kosmogramu — SVG po stronie serwera (PRE-12). + +Sprawdzamy, że SVG jest POPRAWNY (parsuje się jako XML — inaczej wstawiony do +strony rozjedzie render) i że niesie to, co ma nieść: pierścień 12 znaków, cztery +osie i numery domów. Geometria (Asc po lewej) sprawdzona liczbowo, nie na oko. +""" +import xml.dom.minidom as minidom + +from app import chartwheel + + +def _sample_chart(asc=128.9, mc=5.0): + """Minimalny wynik z warstwy logiki: osie + cuspy (whole sign) + pierścień znaków.""" + signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"] + glyphs = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"] + asc_sign = int(asc // 30) + cusps = [{"house": i + 1, "sign": signs[(asc_sign + i) % 12], + "decimal": (asc_sign * 30 + i * 30) % 360, "sign_glyph": glyphs[(asc_sign + i) % 12]} + for i in range(12)] + return { + "angles": { + "Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]}, + "MC": {"name": "MC", "decimal": mc}, + "Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360}, + "IC": {"name": "IC", "decimal": (mc + 180) % 360}, + }, + "cusps": cusps, + "sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)], + } + + +def _rendered(): + return chartwheel.render(_sample_chart()) + + +def test_output_is_well_formed_xml(): + """Wstawiamy surowy SVG do strony — musi być poprawnym XML, bo inaczej + zepsuje cały dokument.""" + svg = _rendered().replace("var(--line)", "#000").replace( + "var(--accent)", "#000").replace("var(--muted)", "#000") + doc = minidom.parseString(svg) # rzuci wyjątkiem, gdy błędny + assert doc.documentElement.tagName == "svg" + + +def test_has_twelve_sign_glyphs(): + assert _rendered().count('class="glyph"') == 12 + + +def test_has_all_four_axis_labels(): + svg = _rendered() + for label in (">AC<", ">DC<", ">MC<", ">IC<"): + assert label in svg + + +def test_has_house_numbers_one_to_twelve(): + svg = _rendered() + for h in range(1, 13): + assert f">{h}<" in svg + + +def test_ascendant_is_on_the_left(): + """Konwencja: Asc po lewej. Sprawdzamy oś AC–DC liczbowo — punkt Asc musi mieć + x wyraźnie mniejszy od środka (220), a Dsc większy.""" + chart = _sample_chart(asc=128.9) + asc = chart["angles"]["Asc"]["decimal"] + ax, ay = chartwheel._pt(asc, asc, chartwheel.R_ZOD) + dx, dy = chartwheel._pt(asc + 180, asc, chartwheel.R_ZOD) + assert ax < chartwheel._CX - 100, "Asc powinien być po lewej" + assert dx > chartwheel._CX + 100, "Dsc powinien być po prawej" + assert abs(ay - chartwheel._CY) < 1e-6, "oś Asc–Dsc jest pozioma" + + +def test_longitude_increases_counterclockwise(): + """Asc+90° (II dom) ląduje na DOLE koła (y > środek, bo w SVG y rośnie w dół).""" + asc = 100.0 + _, y = chartwheel._pt(asc + 90, asc, chartwheel.R_ZOD) + assert y > chartwheel._CY + 100 + + +def test_empty_when_no_angles(): + """Silnik bez osi/domów (brak sidereal) → brak koła, nie wyjątek.""" + assert chartwheel.render({"positions": []}) == "" + assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == "" + + +def test_empty_when_cusps_lack_longitude(): + """Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy.""" + chart = _sample_chart() + for c in chart["cusps"]: + del c["decimal"] + assert chartwheel.render(chart) == ""
ZnakW znaku