feat(prezentacja): aspektarian — siatka aspektów (PRE-18, kosmogram etap 5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m37s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m32s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 25s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 19s
build / build (push) Successful in 48s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m34s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 10m2s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 1m7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 41s

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 <title> (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>
This commit was merged in pull request #38.
This commit is contained in:
2026-07-26 01:13:46 +02:00
parent 623603b157
commit 4b17f2dd67
6 changed files with 207 additions and 2 deletions
+93 -2
View File
@@ -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)}</title>" if title else "" # dymek po najechaniu
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
f' text-anchor="middle" dominant-baseline="central"{c}{f}>{t}{escape(s)}</text>')
f' text-anchor="middle" dominant-baseline="central"{c}{f}{w}>{t}{escape(s)}</text>')
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'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
f'aria-label="Kosmogram — koło horoskopowe" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
# ── 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'<rect x="{dx:.1f}" y="{dy:.1f}" width="{S}" height="{S}" '
f'fill="{T["accent"]}" opacity="0.14" stroke="{T["line"]}" stroke-width="0.6"/>')
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'<rect x="{cx:.1f}" y="{cy:.1f}" width="{S}" height="{S}" '
f'fill="none" stroke="{T["line"]}" stroke-width="0.6"/>')
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'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')