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
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:
@@ -90,6 +90,15 @@ _ASPECT_PL = {
|
|||||||
"semisquare": "półkwadratura", "quincunx": "kwinkunks",
|
"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]:
|
def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
|
||||||
"""Grubość i przezroczystość linii aspektu wg orbu.
|
"""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 = "",
|
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 ""
|
c = f' class="{cls}"' if cls else ""
|
||||||
f = f' font-family="{font}"' if font 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
|
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}"'
|
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]:
|
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" '
|
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
|
||||||
f'aria-label="Kosmogram — koło horoskopowe" '
|
f'aria-label="Kosmogram — koło horoskopowe" '
|
||||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
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>')
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ def chart_compute(
|
|||||||
)
|
)
|
||||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||||
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -143,6 +144,7 @@ def compile_build(
|
|||||||
)
|
)
|
||||||
from app import chartwheel
|
from app import chartwheel
|
||||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||||
|
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||||||
except (httpx.HTTPError,) as e:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ td.glyph { color: var(--accent); white-space: nowrap; }
|
|||||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||||
svg.wheel text.glyph { font-size: inherit; }
|
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,
|
/* 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),
|
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
|
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
|
||||||
|
|||||||
@@ -85,6 +85,13 @@
|
|||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if aspectarian_svg %}
|
||||||
|
<figure class="aspectarian-fig">
|
||||||
|
{{ aspectarian_svg | safe }}
|
||||||
|
<figcaption class="muted small">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.</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if result.angles %}
|
{% if result.angles %}
|
||||||
<table class="angles">
|
<table class="angles">
|
||||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
|||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if aspectarian_svg %}
|
||||||
|
<figure class="aspectarian-fig">
|
||||||
|
{{ aspectarian_svg | safe }}
|
||||||
|
<figcaption class="muted small">Aspektarian — siatka aspektów (PRE-18).</figcaption>
|
||||||
|
</figure>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
|
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
|
||||||
<div class="meta">Horoskop · silnik {{ result.engine }}
|
<div class="meta">Horoskop · silnik {{ result.engine }}
|
||||||
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
|
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
|
||||||
|
|||||||
@@ -337,3 +337,95 @@ def test_print_theme_survives_stage4_features():
|
|||||||
svg = chartwheel.render(chart, theme="print")
|
svg = chartwheel.render(chart, theme="print")
|
||||||
assert "var(--" not in svg
|
assert "var(--" not in svg
|
||||||
assert "⊗" in svg and "<title>" in svg
|
assert "⊗" in svg and "<title>" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user