From 4b17f2dd67af01def79addcc2586287bf1e0dc3e Mon Sep 17 00:00:00 2001 From: migatu Date: Sun, 26 Jul 2026 01:13:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(prezentacja):=20aspektarian=20=E2=80=94=20?= =?UTF-8?q?siatka=20aspekt=C3=B3w=20(PRE-18,=20kosmogram=20etap=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trójkątna siatka aspektów obiekt×obiekt obok koła. Koło pokazuje GEOMETRIĘ aspektów (linie w środku), aspektarian domyka temat od drugiej strony — TABELĘ na jeden rzut oka: kto z kim i jak. Klasyczny „schodkowy" układ: glify obiektów biegną po przekątnej, a każda komórka pod nią to aspekt między obiektem ze swojego wiersza a obiektem ze swojej kolumny. Pusta komórka też niesie informację — że pary NIC nie łączy. Spójność z kołem trzymana świadomie: - te same barwy aspektów (niebieski harmonijny / czerwony napięty / zielony koniunkcja) — oko łapie ten sam kod na kole i w siatce; - ciasny aspekt (orb <1°) pogrubiony, tak jak grubsza linia na kole; - natywne dymki (bez JS): „Słońce trygon Mars · orb 0.20° · aplikacyjny", polskie nazwy tylko w dymku — obliczenia trzymają angielskie; - oba motywy: „screen" (zmienne CSS aplikacji) i „print" (konkretne kolory + font glifów wprost), więc siatka jest gotowa też do PDF-a. Glify aspektów (☌ ☍ △ □ ⚹ ⚺ ⚻ ∠) trzymamy lokalnie w prezentacji — tam gdzie już są kolory i polskie nazwy — więc aspektarian jest samowystarczalny i nie zależy od tego, czy pojedynczy rekord aspektu niesie glif. Renderer degraduje się do pustego przy mniej niż dwóch obiektach; niekompletny obiekt (bez glifu) go nie wywala. Aspektarian pokazuje się na /chart i /compile pod kołem. Testy: +12 (etap 5). Całość prezentacji: 157. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- services/presentation/app/chartwheel.py | 95 ++++++++++++++++++- services/presentation/app/main.py | 2 + services/presentation/app/static/styles.css | 6 ++ .../presentation/app/templates/chart.html | 7 ++ .../presentation/app/templates/compile.html | 7 ++ .../presentation/tests/test_chartwheel.py | 92 ++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py index 41734a8..6810c4a 100644 --- a/services/presentation/app/chartwheel.py +++ b/services/presentation/app/chartwheel.py @@ -90,6 +90,15 @@ _ASPECT_PL = { "semisquare": "półkwadratura", "quincunx": "kwinkunks", } +# Glify aspektów (te same znaki co w warstwie logiki: engine/glyphs.py ASPECT). +# Trzymamy je lokalnie, bo prezentacja ma już u siebie kolory i polskie nazwy +# aspektów — dzięki temu aspektarian jest samowystarczalny i nie zależy od tego, +# czy pojedynczy rekord aspektu niesie glif. +_ASPECT_GLYPH = { + "conjunction": "☌", "opposition": "☍", "trine": "△", "square": "□", + "sextile": "⚹", "semisextile": "⚺", "quincunx": "⚻", "semisquare": "∠", +} + def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]: """Grubość i przezroczystość linii aspektu wg orbu. @@ -129,12 +138,13 @@ GLYPH_FONT = ("Apple Symbols, Segoe UI Symbol, Noto Sans Symbols2, " def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "", - font: str = "", title: str = "") -> str: + font: str = "", title: str = "", weight: str = "") -> str: c = f' class="{cls}"' if cls else "" f = f' font-family="{font}"' if font else "" + w = f' font-weight="{weight}"' if weight else "" t = f"<title>{escape(title)}" if title else "" # dymek po najechaniu return (f'{t}{escape(s)}') + f' text-anchor="middle" dominant-baseline="central"{c}{f}{w}>{t}{escape(s)}') def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]: @@ -352,3 +362,84 @@ def render(chart: dict, theme: str = "screen") -> str: return (f'{body}') + + +# ── aspektarian (PRE-18) ────────────────────────────────────────────────── +ASP_CELL = 30.0 # bok komórki siatki aspektów + + +def render_aspectarian(chart: dict, theme: str = "screen") -> str: + """Aspektarian (PRE-18) — trójkątna siatka aspektów obiekt×obiekt jako SVG. + + Klasyczny „schodkowy" układ: glify obiektów biegną po przekątnej, a każda + komórka POD nią to aspekt między obiektem ze swojego wiersza i ze swojej + kolumny — albo pusto, gdy pary nie łączy żaden aspekt (brak też coś mówi). + Koło pokazuje GEOMETRIĘ aspektów, aspektarian — ich TABELĘ na jeden rzut oka. + + Kolory i pogrubienie ciasnych aspektów są SPÓJNE z liniami na kole (ten sam + motyw i te same barwy). Zwraca '' przy mniej niż dwóch obiektach. + + theme='print' — konkretne kolory + font glifów wprost w elementach, bo SVG + trafia wtedy do samodzielnego konwertera (PDF), który arkusza CSS nie widzi. + """ + T = _THEMES.get(theme, _THEMES["screen"]) + glyph_font = GLYPH_FONT if theme == "print" else "" + + objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None] + n = len(objects) + if n < 2: + return "" + + # aspekt po parze NAZW — kolejność obiektów w parze nie ma znaczenia + by_pair: dict = {} + for a in (chart.get("aspects") or []): + by_pair[frozenset((a.get("obj1"), a.get("obj2")))] = a + + S = ASP_CELL + pad = 1.0 + dim = n * S + 2 * pad + parts: list[str] = [] + + for i, oi in enumerate(objects): + # przekątna: glif obiektu na delikatnym tle — kręgosłup siatki + dx = pad + i * S + dy = pad + i * S + parts.append( + f'') + dtip = oi.get("name") or "" + if oi.get("in_sign"): + dtip += f" · {oi['in_sign']}" + parts.append(_text(dx + S / 2, dy + S / 2, oi.get("glyph") or "", size=15, + fill=T["ink"], cls="glyph", font=glyph_font, title=dtip)) + + # komórki aspektów w tym wierszu: kolumny j < i (dolny-lewy trójkąt) + for j in range(i): + oj = objects[j] + cx = pad + j * S + cy = pad + i * S + parts.append( + f'') + asp = by_pair.get(frozenset((oi.get("name"), oj.get("name")))) + if not asp: + continue + name = asp.get("aspect") + glyph = _ASPECT_GLYPH.get(name) + if not glyph: + continue + orb = float(asp.get("orb", 0.0)) + tight = orb < 1.0 # jak na kole: ciasny = wyróżniony + color = T["aspects"].get(name, T["muted"]) + tip = (f'{oi.get("name")} {_ASPECT_PL.get(name, name)} {oj.get("name")} · ' + f'orb {orb:.2f}°') + if asp.get("as"): + tip += " · " + ("aplikacyjny" if asp["as"] == "A" else "separacyjny") + parts.append(_text(cx + S / 2, cy + S / 2, glyph, size=17 if tight else 14, + fill=color, cls="glyph", font=glyph_font, title=tip, + weight="bold" if tight else "")) + + body = "".join(parts) + return (f'{body}') diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 7c83d04..cffbc3a 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -96,6 +96,7 @@ 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 except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: @@ -143,6 +144,7 @@ def compile_build( ) from app import chartwheel ctx["wheel_svg"] = chartwheel.render(ctx["result"]) + ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 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 467aac5..7067def 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -51,6 +51,12 @@ td.glyph { color: var(--accent); white-space: nowrap; } /* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */ svg.wheel text.glyph { font-size: inherit; } +/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */ +.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; } +.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; } +.aspectarian-fig figcaption { margin-top: .4rem; } +svg.aspectarian 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 7c52477..0aa2f90 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -85,6 +85,13 @@ {% endif %} + {% if aspectarian_svg %} +
+ {{ aspectarian_svg | safe }} +
Aspektarian — siatka aspektów obiekt×obiekt. Glify po przekątnej to obiekty; komórka pod przekątną to aspekt między obiektem z jej wiersza a obiektem z jej kolumny (pusto = brak aspektu). Barwy jak na kole: niebieski — harmonijny, czerwony — napięty, zielony — koniunkcja; pogrubiony = orb poniżej 1°. Najedź na komórkę po szczegóły.
+
+ {% endif %} + {% if result.angles %} diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html index 33c60bd..34472d3 100644 --- a/services/presentation/app/templates/compile.html +++ b/services/presentation/app/templates/compile.html @@ -84,6 +84,13 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t {% endif %} + {% if aspectarian_svg %} +
+ {{ aspectarian_svg | safe }} +
Aspektarian — siatka aspektów (PRE-18).
+
+ {% endif %} + {# ── 3. Dane policzone ────────────────────────────────────────────── #}
Horoskop · silnik {{ result.engine }} {% if result.house_system %}· domy {{ result.house_system }}{% endif %} diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py index ae0bd6e..1612856 100644 --- a/services/presentation/tests/test_chartwheel.py +++ b/services/presentation/tests/test_chartwheel.py @@ -337,3 +337,95 @@ def test_print_theme_survives_stage4_features(): svg = chartwheel.render(chart, theme="print") assert "var(--" not in svg assert "⊗" in svg and "" in svg + + +# ─────────────────────────── etap 5: aspektarian (PRE-18) ───────────────── + +def test_aspectarian_is_well_formed_xml(): + """Aspektarian też wstawiamy surowo do strony — musi być poprawnym XML.""" + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + svg = (svg.replace("var(--line)", "#000").replace("var(--accent)", "#000") + .replace("var(--muted)", "#000").replace("var(--ink)", "#000")) + doc = minidom.parseString(svg) + assert doc.documentElement.tagName == "svg" + + +def test_aspectarian_triangular_grid_has_right_cell_count(): + """N obiektów → przekątna (N komórek) + dolny-lewy trójkąt (N·(N−1)/2). + Fixture ma 5 obiektów: 5 + 10 = 15 komórek.""" + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + assert svg.count("<rect") == 15 + + +def test_aspectarian_puts_object_glyphs_on_diagonal(): + """Każdy obiekt raz — na przekątnej.""" + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + for glyph in ("☉", "☽", "♀", "☿", "♂"): + assert glyph in svg + + +def test_aspectarian_shows_aspect_glyphs(): + """Aspekty z fixture: trygon (△), kwadratura (□), koniunkcja (☌).""" + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + assert "△" in svg and "□" in svg and "☌" in svg + + +def test_aspectarian_colors_match_the_wheel(): + """Te same barwy co linie na kole: niebieski/czerwony/zielony.""" + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + assert "#6aa6c9" in svg # trine — harmonijny + assert "#cf6a6a" in svg # square — napięty + assert "#8fae7a" in svg # conjunction — zielony + + +def test_aspectarian_tight_aspect_is_bold(): + """Orb < 1° → glif pogrubiony (jak pogrubiona linia na kole). Dwa ciasne + aspekty w fixture (Sun–Mars 0.2°, Mercury–Venus 0.4°) → dwa pogrubienia.""" + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + assert svg.count('font-weight="bold"') == 2 + + +def test_aspectarian_cells_have_tooltips(): + svg = chartwheel.render_aspectarian(_chart_with_aspects()) + assert "<title>" in svg + assert "trygon" in svg and "orb" in svg + + +def test_aspectarian_tooltip_shows_applying_separating(): + """Gdy aspekt niesie kierunek (A/S), dymek to mówi po polsku.""" + chart = _chart_with_aspects() + chart["aspects"][0]["as"] = "A" # Sun–Mars aplikacyjny + svg = chartwheel.render_aspectarian(chart) + assert "aplikacyjny" in svg + + +def test_aspectarian_empty_for_fewer_than_two_objects(): + chart = _chart_with_objects() + chart["positions"] = chart["positions"][:1] + assert chartwheel.render_aspectarian(chart) == "" + + +def test_aspectarian_renders_grid_even_without_aspects(): + """Pusta siatka też niesie informację (które pary NIE mają aspektu) — + rysujemy przekątną z obiektami, bez glifów aspektów.""" + chart = _chart_with_objects() + chart["aspects"] = [] + svg = chartwheel.render_aspectarian(chart) + assert svg and "☉" in svg + for asp_glyph in ("△", "□", "☌", "⚹", "☍"): + assert asp_glyph not in svg + + +def test_aspectarian_object_without_glyph_is_safe(): + """Niekompletny obiekt (bez glifu) nie wywala siatki.""" + chart = _chart_with_aspects() + chart["positions"].append({"name": "Chiron", "decimal": 100.0}) # brak glyph + svg = chartwheel.render_aspectarian(chart) + assert svg and "☉" in svg + + +def test_aspectarian_print_theme_has_no_css_vars(): + """Motyw druku (PDF) — konkretne kolory, żaden `var(--…)`.""" + svg = chartwheel.render_aspectarian(_chart_with_aspects(), theme="print") + assert "var(--" not in svg + assert "#a83232" in svg # square napięty w wariancie druku
ZnakW znaku