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"
{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'')
+
+
+# ── 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'')
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 %}
Oś
Znak
W znaku
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("" 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