From adce5687297f06a828f2be71e75b63bd867331a2 Mon Sep 17 00:00:00 2001 From: migatu Date: Tue, 28 Jul 2026 17:17:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(prezentacja):=20wykres=20deklinacji=20i=20?= =?UTF-8?q?o=C5=9B=20antyscji=20(LOG-07,=20kosmogram=20etap=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ostatni etap rysowania kosmogramu: wizualizacje pochodne z aspektów pozazodiakalnych (LOG-07). Do tej pory paralele i antyscja były tylko w tabelach — teraz widać je na rzut oka. WYKRES DEKLINACJI (`render_declination`): - Pionowa skala deklinacji z równikiem (0°) i zwrotnikami (±ε, kreskowane) — ε bierzemy z `obliquity` w wyniku, więc granica jest dokładna dla daty. - Obiekty na osi X ułożone wg POSORTOWANEJ deklinacji, więc paralele (ta sama wysokość) lądują obok siebie. Łączniki: paralela zielona (jak koniunkcja), kontrparalela czerwona (jak opozycja) — te same barwy co linie na kole, grubość wg orbu. Dymek z nazwą zjawiska i orbem. - Strefa poza zwrotnikami cieniowana; obiekt OOB (out-of-bounds) w kolorze wyróżnienia + „OOB" w dymku. Od razu widać ciała o skrajnej deklinacji. OŚ ANTYSCJI (`render_antiscia`): - Ekliptyka rozwinięta w poziomą oś ze znakami; pionowo zaznaczona oś przesileń (0° Raka/Koziorożca) — lustro antyscji — i oś równonocy (0° Barana/Wagi) dla kontrantyscji. Pary połączone łukiem (zielony antyscja / czerwony kontrantyscja), z dymkiem. Bez par oś i obiekty i tak coś mówią. Oba w obu motywach (screen + print), więc gotowe też do PDF-a. Pokazują się na /chart pod odpowiednimi tabelami LOG-07. Testy: +13 (etap 6). Prezentacja: 170. Domyka etapy kosmogramu (PRE-12): 1 szkielet, 2 obiekty, 3 aspekty, 4 dopracowanie, 5 aspektarian, 6 deklinacja/antyscja. Co-Authored-By: Claude Opus 4.8 --- services/presentation/app/chartwheel.py | 161 ++++++++++++++++++ services/presentation/app/main.py | 2 + services/presentation/app/static/styles.css | 7 + .../presentation/app/templates/chart.html | 14 ++ .../presentation/tests/test_chartwheel.py | 114 +++++++++++++ 5 files changed, 298 insertions(+) diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py index 6810c4a..6581267 100644 --- a/services/presentation/app/chartwheel.py +++ b/services/presentation/app/chartwheel.py @@ -443,3 +443,164 @@ def render_aspectarian(chart: dict, theme: str = "screen") -> str: return (f'{body}') + + +# ── deklinacja i antyscja (LOG-07 → kosmogram etap 6) ───────────────────── +# Polskie nazwy zjawisk pozazodiakalnych — tylko do dymków. +_OOZ_PL = { + "parallel": "paralela", "contraparallel": "kontrparalela", + "antiscion": "antyscja", "contra_antiscion": "kontrantyscja", +} + + +def _dashed(x1: float, y1: float, x2: float, y2: float, stroke: str, + w: float = 0.8, opacity: float = 0.9, dash: str = "4 3") -> str: + return (f'') + + +_DECL_W, _DECL_H = 470.0, 300.0 +_DPX0, _DPX1 = 54.0, 452.0 # obszar rysunku w poziomie +_DPY0, _DPY1 = 30.0, 262.0 # góra (+Rmax) i dół (−Rmax) + + +def render_declination(chart: dict, theme: str = "screen") -> str: + """Wykres deklinacji (LOG-07, etap 6). Pionowa skala deklinacji: obiekty na + tej samej wysokości są w PARALELI (działa jak koniunkcja poza ekliptyką), na + lustrzanych względem równika — w KONTRPARALELI (jak opozycja). Zwrotniki (±ε) + to granica „poza zakresem" (OOB): ciało za nimi ma większą deklinację niż + kiedykolwiek Słońce. Kolory łączników jak na kole. '' przy < 2 obiektach.""" + T = _THEMES.get(theme, _THEMES["screen"]) + glyph_font = GLYPH_FONT if theme == "print" else "" + + objs = [p for p in (chart.get("positions") or []) if p.get("declination") is not None] + if len(objs) < 2: + return "" + eps = float(chart.get("obliquity") or 23.4367) + maxabs = max(abs(float(p["declination"])) for p in objs) + Rmax = max(eps + 3.0, maxabs + 2.0) # skala mieści zwrotniki i OOB + + def y_of(dec: float) -> float: + return _DPY0 + (Rmax - dec) / (2.0 * Rmax) * (_DPY1 - _DPY0) + + parts: list[str] = [] + # strefy OOB (za zwrotnikami) — delikatne tło + yN, yS = y_of(eps), y_of(-eps) + parts.append(f'') + parts.append(f'') + # równik (0°) i zwrotniki (±ε) + yeq = y_of(0.0) + parts.append(_line(_DPX0, yeq, _DPX1, yeq, T["line"], 1.0, 0.9)) + parts.append(_text(_DPX0 - 8, yeq, "0°", size=8, fill=T["muted"])) + for d, lab in ((eps, f"+{eps:.0f}°"), (-eps, f"−{eps:.0f}°")): + yy = y_of(d) + parts.append(_dashed(_DPX0, yy, _DPX1, yy, T["accent"], 0.8, 0.8)) + parts.append(_text(_DPX0 - 8, yy, lab, size=8, fill=T["accent"])) + parts.append(_text((_DPX0 + _DPX1) / 2, 16, + "deklinacja — poza ±ε (zwrotniki) obiekt jest OOB", size=8, fill=T["muted"])) + + # X równomiernie wg POSORTOWANEJ deklinacji — paralele lądują obok siebie + order = sorted(range(len(objs)), key=lambda i: float(objs[i]["declination"])) + n = len(order) + xy: dict = {} + for slot, idx in enumerate(order): + x = _DPX0 + (slot + 0.5) / n * (_DPX1 - _DPX0) + xy[objs[idx]["name"]] = (x, y_of(float(objs[idx]["declination"]))) + + # łączniki paralel/kontrparalel (zielony/czerwony — jak koniunkcja/opozycja) + for par in (chart.get("parallels") or []): + a = xy.get(par.get("obj1")); b = xy.get(par.get("obj2")) + if not a or not b: + continue + kind = par.get("type") + color = T["aspects"]["conjunction"] if kind == "parallel" else T["aspects"]["opposition"] + orb = float(par.get("orb", 0.0)) + w, op = _aspect_pen(orb, par.get("allowed")) + tip = f'{par.get("obj1")} {_OOZ_PL.get(kind, kind)} {par.get("obj2")} · orb {orb:.2f}°' + parts.append(_line(a[0], a[1], b[0], b[1], color, w, op, title=tip)) + + # glify na wysokości ich deklinacji + podpis stopnia; OOB wyróżnione + for p in objs: + x, y = xy[p["name"]] + oob = bool(p.get("out_of_bounds")) + dec = float(p["declination"]) + tip = f'{p.get("name")} · dekl. {dec:+.2f}°' + (" · OOB" if oob else "") + parts.append(_text(x, y, p.get("glyph") or "", size=15, + fill=T["retro"] if oob else T["ink"], + cls="glyph", font=glyph_font, title=tip)) + parts.append(_text(x, y + 12, f"{dec:+.1f}", size=7, + fill=T["retro"] if oob else T["muted"])) + + body = "".join(parts) + return (f'{body}') + + +_ANT_W, _ANT_H = 470.0, 150.0 +_ANT_X0, _ANT_X1 = 22.0, 448.0 +_ANT_Y = 100.0 # oś ekliptyki + + +def render_antiscia(chart: dict, theme: str = "screen") -> str: + """Oś antyscji (LOG-07, etap 6). Ekliptyka rozwinięta w poziomą oś; pionowo + zaznaczona OŚ PRZESILEŃ (0° Raka / 0° Koziorożca) — względem niej odbija się + antyscja, a względem osi równonocy (0° Barana / Wagi) — kontrantyscja. Pary + połączone łukiem (zielony antyscja / czerwony kontrantyscja). '' przy < 2 + obiektach.""" + T = _THEMES.get(theme, _THEMES["screen"]) + glyph_font = GLYPH_FONT if theme == "print" else "" + objs = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None] + if len(objs) < 2: + return "" + + def x_of(lon: float) -> float: + return _ANT_X0 + (lon % 360.0) / 360.0 * (_ANT_X1 - _ANT_X0) + + parts: list[str] = [] + parts.append(_line(_ANT_X0, _ANT_Y, _ANT_X1, _ANT_Y, T["line"], 1.0, 0.9)) + signs = chart.get("sign_glyphs") or [] + for i in range(12): + xb = x_of(i * 30.0) + parts.append(_line(xb, _ANT_Y - 5, xb, _ANT_Y + 5, T["line"], 0.6, 0.6)) + if i < len(signs): + parts.append(_text(x_of(i * 30.0 + 15.0), _ANT_Y + 17, signs[i].get("glyph") or "", + size=11, fill=T["muted"], cls="glyph", font=glyph_font)) + # osie odbicia: przesileń (90/270) mocno, równonocy (0/180) delikatnie + for lon, col, op in ((90.0, T["accent"], 0.9), (270.0, T["accent"], 0.9), + (0.0, T["muted"], 0.5), (180.0, T["muted"], 0.5)): + xx = x_of(lon) + parts.append(_dashed(xx, _ANT_Y - 44, xx, _ANT_Y + 24, col, 0.9, op)) + parts.append(_text((_ANT_X0 + _ANT_X1) / 2, 15, + "oś przesileń — antyscja · oś równonocy — kontrantyscja", + size=8, fill=T["muted"])) + + lon_by = {p["name"]: float(p["decimal"]) for p in objs} + for row in (chart.get("antiscia") or []): + la = lon_by.get(row.get("obj1")); lb = lon_by.get(row.get("obj2")) + if la is None or lb is None: + continue + kind = row.get("type") + color = T["aspects"]["conjunction"] if kind == "antiscion" else T["aspects"]["opposition"] + xa, xb = x_of(la), x_of(lb) + orb = float(row.get("orb", 0.0)) + tip = f'{row.get("obj1")} {_OOZ_PL.get(kind, kind)} {row.get("obj2")} · orb {orb:.2f}°' + midx = (xa + xb) / 2.0 + top = _ANT_Y - 24 - abs(xb - xa) * 0.05 + parts.append(f'{escape(tip)}') + + for p in objs: + x = x_of(float(p["decimal"])) + parts.append(_text(x, _ANT_Y - 12, p.get("glyph") or "", size=12, fill=T["ink"], + cls="glyph", font=glyph_font, + title=f'{p.get("name")} · {p.get("in_sign", "")}'.strip(" ·"))) + + body = "".join(parts) + return (f'{body}') diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index cffbc3a..df2e16e 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -97,6 +97,8 @@ def chart_compute( from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera ctx["wheel_svg"] = chartwheel.render(ctx["result"]) ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 + ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6) + ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6) 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 7067def..795d017 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -57,6 +57,13 @@ svg.wheel text.glyph { font-size: inherit; } .aspectarian-fig figcaption { margin-top: .4rem; } svg.aspectarian text.glyph { font-size: inherit; } +/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */ +.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; } +.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; } +.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; } +.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; } +svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; } + /* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran, ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000), ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 0aa2f90..784edec 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -170,6 +170,13 @@ {% endif %} {# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #} + {% if declination_svg %} +
+ {{ declination_svg | safe }} +
Wykres deklinacji — obiekty na tej samej wysokości są w paraleli (jak koniunkcja poza ekliptyką, zielony łącznik), lustrzane względem równika — w kontrparaleli (jak opozycja, czerwony). Poza zwrotnikami (±ε, strefa cieniowana) obiekt jest OOB. Najedź po szczegóły.
+
+ {% endif %} + {% if result.parallels %}
Paralele deklinacji ({{ result.parallels | length }})
@@ -188,6 +195,13 @@
{% endif %} + {% if antiscia_svg %} +
+ {{ antiscia_svg | safe }} +
Oś antyscji — ekliptyka rozwinięta w poziom; pionowa oś przesileń (0° Raka / Koziorożca) to lustro antyscji, oś równonocy (0° Barana / Wagi) — kontrantyscji. Łuki łączą pary. Najedź po szczegóły.
+
+ {% endif %} + {% if result.antiscia %}
Antyscja ({{ result.antiscia | length }})
diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py index 1612856..0ea216e 100644 --- a/services/presentation/tests/test_chartwheel.py +++ b/services/presentation/tests/test_chartwheel.py @@ -429,3 +429,117 @@ def test_aspectarian_print_theme_has_no_css_vars(): svg = chartwheel.render_aspectarian(_chart_with_aspects(), theme="print") assert "var(--" not in svg assert "#a83232" in svg # square napięty w wariancie druku + + +# ─────────────── etap 6: deklinacja i antyscja (LOG-07) ─────────────────── + +def _chart_with_declination(): + chart = _chart_with_objects() # Sun/Moon/Venus/Mercury + chart["obliquity"] = 23.44 + decs = {"Sun": 15.0, "Moon": 15.2, "Venus": -15.0, "Mercury": 25.5} + for p in chart["positions"]: + p["declination"] = decs[p["name"]] + if p["name"] == "Mercury": + p["out_of_bounds"] = True # 25.5° > ε=23.44 → OOB + chart["parallels"] = [ + {"obj1": "Sun", "obj2": "Moon", "type": "parallel", + "orb": 0.2, "allowed": 1.0, "dec1": 15.0, "dec2": 15.2}, + {"obj1": "Sun", "obj2": "Venus", "type": "contraparallel", + "orb": 0.0, "allowed": 1.0, "dec1": 15.0, "dec2": -15.0}, + ] + return chart + + +def test_declination_is_well_formed_xml(): + svg = chartwheel.render_declination(_chart_with_declination()) + svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000") + .replace("var(--muted)", "#000").replace("var(--ink)", "#000")) + assert minidom.parseString(svg).documentElement.tagName == "svg" + + +def test_declination_empty_for_fewer_than_two(): + chart = _chart_with_declination() + chart["positions"] = chart["positions"][:1] + assert chartwheel.render_declination(chart) == "" + + +def test_declination_draws_equator_and_tropics(): + """Równik (0°) i dwie linie zwrotników (kreskowane) muszą być.""" + svg = chartwheel.render_declination(_chart_with_declination()) + assert ">0°<" in svg + assert svg.count("stroke-dasharray") >= 2 # ±ε kreskowane + + +def test_declination_object_glyphs_present(): + svg = chartwheel.render_declination(_chart_with_declination()) + for g in ("☉", "☽", "♀", "☿"): + assert g in svg + + +def test_declination_oob_object_is_highlighted(): + """Merkury poza zwrotnikami (OOB) w kolorze wyróżnienia + z „OOB" w dymku.""" + svg = chartwheel.render_declination(_chart_with_declination()) + assert "#ff9b6a" in svg # kolor retro/OOB (motyw ekranowy) + assert "OOB" in svg + + +def test_declination_parallel_and_contraparallel_colors(): + """Paralela jak koniunkcja (zielony), kontrparalela jak opozycja (czerwony).""" + svg = chartwheel.render_declination(_chart_with_declination()) + assert "#8fae7a" in svg # parallel → zielony + assert "#cf6a6a" in svg # contraparallel → czerwony + assert "paralela" in svg # dymek + + +def test_declination_print_theme_has_no_css_vars(): + svg = chartwheel.render_declination(_chart_with_declination(), theme="print") + assert "var(--" not in svg + + +def _chart_with_antiscia(): + chart = _chart_with_objects() + chart["antiscia"] = [ + {"obj1": "Sun", "obj2": "Moon", "type": "antiscion", "orb": 0.3, "allowed": 1.0}, + {"obj1": "Venus", "obj2": "Mercury", "type": "contra_antiscion", "orb": 0.5, "allowed": 1.0}, + ] + return chart + + +def test_antiscia_is_well_formed_xml(): + svg = chartwheel.render_antiscia(_chart_with_antiscia()) + svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000") + .replace("var(--muted)", "#000").replace("var(--ink)", "#000")) + assert minidom.parseString(svg).documentElement.tagName == "svg" + + +def test_antiscia_empty_for_fewer_than_two(): + chart = _chart_with_antiscia() + chart["positions"] = chart["positions"][:1] + assert chartwheel.render_antiscia(chart) == "" + + +def test_antiscia_draws_reflection_axes(): + """Oś przesileń (i równonocy) — linie kreskowane.""" + svg = chartwheel.render_antiscia(_chart_with_antiscia()) + assert svg.count("stroke-dasharray") >= 2 + + +def test_antiscia_pair_colors_and_tooltip(): + """Antyscja jak koniunkcja (zielony łuk), kontrantyscja jak opozycja (czerwony).""" + svg = chartwheel.render_antiscia(_chart_with_antiscia()) + assert "#8fae7a" in svg # antiscion → zielony + assert "#cf6a6a" in svg # contra_antiscion → czerwony + assert "antyscja" in svg and "