feat(prezentacja): szkielet kosmogramu — koło horoskopowe SVG (PRE-12)
build / build (push) Successful in 1m26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m10s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 38s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
build / build (push) Successful in 1m26s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m10s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 38s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Pierwszy krok rysowania wykresu (PRE-12): SZKIELET koła — pierścień znaków, podział na domy i osie. Planety i linie aspektów w kolejnych iteracjach. Rysowane po stronie serwera jako czysty string SVG (bez zależności zewnętrznych — spójne z CSP). Ciemny motyw, spójny z paletą aplikacji (kolory z --line, --accent, --muted; glify znaków barwione wg żywiołu w stonowanych kolorach czytelnych na ciemnym tle). Geometria wg konwencji astrologicznej: Ascendent po LEWEJ, długość ekliptyczna rośnie przeciwnie do ruchu wskazówek zegara. Punkt λ → kąt φ = 180° − (λ − Asc); w SVG y rośnie w dół, co formuła uwzględnia. Zakotwiczenie sprawdzone testami liczbowo: Asc po lewej, Dsc po prawej, oś pozioma; Asc+90° (II dom) na dole. Elementy: dwa okręgi + piasta, podziałki co 5°/10°, granice znaków co 30° z glifem w środku sektora, szprychy domów od pasa do piasty, numery domów w środku każdego domu, osie Asc–Dsc i MC–IC wyróżnione akcentem z etykietami AC/DC/MC/IC. Dane bierzemy WYŁĄCZNIE z wyniku /chart/positions — prezentacja nic nie liczy: - `sign_glyphs` (pierścień 12 znaków) i glify — z LOG-22, - `angles` z długością `decimal` — już były, - `cusps` z `decimal` — DOŁOŻONE w tym PR (jedna linia w build_chart). Bez tego domy dało się narysować tylko dla whole sign; z długością cuspu działa dla KAŻDEGO systemu. Zweryfikowane na porphyry: cuspy poza wielokrotnościami 30°, szprychy odchodzą od granic znaków. Degradacja: silnik bez osi/domów albo starszy wynik bez `decimal` w cuspach → brak koła (pusty string), nie wyjątek. Testy: 8 (poprawność XML, 12 glifów, 4 osie, numery domów, Asc po lewej liczbowo, CCW, degradacja). Całość: prezentacja 33 passed, logika 265 / 1 skipped. Render potwierdzony wizualnie na horoskopie referencyjnym (AC=Leo po lewej, MC=Aries u góry, domy CCW). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #26.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""Kosmogram — szkielet koła horoskopowego jako SVG (PRE-12).
|
||||
|
||||
Rysujemy po stronie serwera, bez zależności zewnętrznych (spójne z CSP): czysty
|
||||
string SVG wstawiany do strony. Ten krok to SZKIELET — pierścień znaków, podział
|
||||
na domy i osie. Planety i linie aspektów dojdą w kolejnych iteracjach.
|
||||
|
||||
Geometria (konwencja astrologiczna):
|
||||
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
|
||||
ruchu wskazówek zegara — jak na niebie i w każdym programie.
|
||||
* Punkt o długości λ trafia na kąt φ = 180° − (λ − Asc). Zakotwiczenie:
|
||||
λ=Asc → lewo, λ=Asc+90° → dół, λ=Asc+270° (≈MC) → góra. W SVG y rośnie w dół,
|
||||
co ta formuła już uwzględnia (sin dodatni = w dół).
|
||||
|
||||
Dane bierzemy z wyniku /chart/positions (warstwa logiki): `angles` (z długością
|
||||
`decimal`), `cusps` (z `decimal` — dołożone pod ten widok) i `sign_glyphs`
|
||||
(pierścień 12 znaków z glifami — LOG-22). Prezentacja niczego nie liczy sama.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from html import escape
|
||||
|
||||
# ── promienie (viewBox 440×440, środek 220,220) ─────────────────────────
|
||||
_CX = _CY = 220.0
|
||||
R_OUT = 210.0 # zewnętrzny okrąg
|
||||
R_ZOD = 178.0 # wewnętrzna krawędź pasa znaków
|
||||
R_SIGN = 194.0 # promień glifów znaków (środek pasa)
|
||||
R_TICK = 172.0 # koniec drobnych podziałek stopni (do wewnątrz od pasa)
|
||||
R_HUB = 48.0 # centralne kółko — tu kończą się szprychy domów i osie
|
||||
R_HNUM = 64.0 # promień numerów domów (tuż przy piaście)
|
||||
|
||||
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
|
||||
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
|
||||
|
||||
|
||||
def _pt(lon: float, asc: float, r: float) -> tuple[float, float]:
|
||||
phi = math.radians(180.0 - (lon - asc))
|
||||
return _CX + r * math.cos(phi), _CY + r * math.sin(phi)
|
||||
|
||||
|
||||
def _line(x1: float, y1: float, x2: float, y2: float, stroke: str, w: float = 1.0,
|
||||
opacity: float = 1.0) -> str:
|
||||
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}"/>')
|
||||
|
||||
|
||||
def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "") -> str:
|
||||
c = f' class="{cls}"' if cls else ""
|
||||
return (f'<text x="{x:.2f}" y="{y:.2f}" font-size="{size}" fill="{fill}"'
|
||||
f' text-anchor="middle" dominant-baseline="central"{c}>{escape(s)}</text>')
|
||||
|
||||
|
||||
def render(chart: dict) -> str:
|
||||
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów)."""
|
||||
angles = chart.get("angles")
|
||||
cusps = chart.get("cusps")
|
||||
signs = chart.get("sign_glyphs")
|
||||
if not (angles and cusps and signs) or "decimal" not in (cusps[0] if cusps else {}):
|
||||
return ""
|
||||
|
||||
asc = float(angles["Asc"]["decimal"])
|
||||
parts: list[str] = []
|
||||
|
||||
# tło i okręgi
|
||||
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_OUT}" fill="#12132a" stroke="var(--line)" stroke-width="1.5"/>')
|
||||
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_ZOD}" fill="none" stroke="var(--line)" stroke-width="1"/>')
|
||||
parts.append(f'<circle cx="{_CX}" cy="{_CY}" r="{R_HUB}" fill="none" stroke="var(--line)" stroke-width="1"/>')
|
||||
|
||||
# drobne podziałki co 5°, mocniejsze co 10° (na pasie znaków)
|
||||
for deg in range(0, 360, 5):
|
||||
r_in = R_TICK if deg % 10 else R_TICK - 4
|
||||
x1, y1 = _pt(deg, asc, R_ZOD)
|
||||
x2, y2 = _pt(deg, asc, r_in)
|
||||
parts.append(_line(x1, y1, x2, y2, "var(--line)", 0.6, 0.7))
|
||||
|
||||
# pas znaków: granice co 30° + glif znaku w środku sektora, kolorem żywiołu
|
||||
for i, sg in enumerate(signs):
|
||||
b = 30.0 * i
|
||||
x1, y1 = _pt(b, asc, R_ZOD)
|
||||
x2, y2 = _pt(b, asc, R_OUT)
|
||||
parts.append(_line(x1, y1, x2, y2, "var(--line)", 1.0))
|
||||
gx, gy = _pt(b + 15.0, asc, R_SIGN)
|
||||
parts.append(_text(gx, gy, sg.get("glyph") or "", size=17,
|
||||
fill=_ELEMENT_COLOR[i % 4], cls="glyph"))
|
||||
|
||||
# szprychy domów (od pasa do piasty) + numery domów w środku każdego domu
|
||||
n = len(cusps)
|
||||
for i, c in enumerate(cusps):
|
||||
lon = float(c["decimal"])
|
||||
x1, y1 = _pt(lon, asc, R_ZOD)
|
||||
x2, y2 = _pt(lon, asc, R_HUB)
|
||||
parts.append(_line(x1, y1, x2, y2, "var(--line)", 0.8, 0.85))
|
||||
nxt = float(cusps[(i + 1) % n]["decimal"])
|
||||
span = (nxt - lon) % 360.0 or 360.0
|
||||
mid = lon + span / 2.0
|
||||
hx, hy = _pt(mid, asc, R_HNUM)
|
||||
parts.append(_text(hx, hy, str(c["house"]), size=11, fill="var(--muted)"))
|
||||
|
||||
# osie: Asc–Dsc i MC–IC przez całe koło, wyróżnione akcentem
|
||||
for a, b, la, lb in (("Asc", "Dsc", "AC", "DC"), ("MC", "IC", "MC", "IC")):
|
||||
lon_a = float(angles[a]["decimal"])
|
||||
ax, ay = _pt(lon_a, asc, R_ZOD)
|
||||
bx, by = _pt(lon_a + 180.0, asc, R_ZOD)
|
||||
parts.append(_line(ax, ay, bx, by, "var(--accent)", 1.6, 0.9))
|
||||
# etykiety tuż przy krawędzi pasa
|
||||
lax, lay = _pt(lon_a, asc, R_ZOD - 12)
|
||||
lbx, lby = _pt(lon_a + 180.0, asc, R_ZOD - 12)
|
||||
parts.append(_text(lax, lay, la, size=10, fill="var(--accent)"))
|
||||
parts.append(_text(lbx, lby, lb, size=10, fill="var(--accent)"))
|
||||
|
||||
body = "".join(parts)
|
||||
return (f'<svg class="wheel" viewBox="0 0 440 440" role="img" '
|
||||
f'aria-label="Kosmogram — szkielet koła horoskopowego" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||
@@ -92,6 +92,8 @@ def chart_compute(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||
)
|
||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||
except (httpx.HTTPError,) as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
except ValueError as e:
|
||||
|
||||
@@ -44,6 +44,12 @@ td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||
"DejaVu Sans", sans-serif; }
|
||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||
/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
|
||||
.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
|
||||
.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
|
||||
.wheel-fig figcaption { margin-top: .4rem; }
|
||||
/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
|
||||
svg.wheel text.glyph { font-size: inherit; }
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
|
||||
@@ -71,6 +71,13 @@
|
||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if wheel_svg %}
|
||||
<figure class="wheel-fig">
|
||||
{{ wheel_svg | safe }}
|
||||
<figcaption class="muted small">Kosmogram — szkielet (znaki, domy, osie). Planety i aspekty w kolejnych iteracjach.</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Szkielet kosmogramu — SVG po stronie serwera (PRE-12).
|
||||
|
||||
Sprawdzamy, że SVG jest POPRAWNY (parsuje się jako XML — inaczej wstawiony do
|
||||
strony rozjedzie render) i że niesie to, co ma nieść: pierścień 12 znaków, cztery
|
||||
osie i numery domów. Geometria (Asc po lewej) sprawdzona liczbowo, nie na oko.
|
||||
"""
|
||||
import xml.dom.minidom as minidom
|
||||
|
||||
from app import chartwheel
|
||||
|
||||
|
||||
def _sample_chart(asc=128.9, mc=5.0):
|
||||
"""Minimalny wynik z warstwy logiki: osie + cuspy (whole sign) + pierścień znaków."""
|
||||
signs = ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"]
|
||||
glyphs = ["♈", "♉", "♊", "♋", "♌", "♍", "♎", "♏", "♐", "♑", "♒", "♓"]
|
||||
asc_sign = int(asc // 30)
|
||||
cusps = [{"house": i + 1, "sign": signs[(asc_sign + i) % 12],
|
||||
"decimal": (asc_sign * 30 + i * 30) % 360, "sign_glyph": glyphs[(asc_sign + i) % 12]}
|
||||
for i in range(12)]
|
||||
return {
|
||||
"angles": {
|
||||
"Asc": {"name": "Asc", "decimal": asc, "sign": signs[asc_sign]},
|
||||
"MC": {"name": "MC", "decimal": mc},
|
||||
"Dsc": {"name": "Dsc", "decimal": (asc + 180) % 360},
|
||||
"IC": {"name": "IC", "decimal": (mc + 180) % 360},
|
||||
},
|
||||
"cusps": cusps,
|
||||
"sign_glyphs": [{"sign": s, "glyph": g} for s, g in zip(signs, glyphs)],
|
||||
}
|
||||
|
||||
|
||||
def _rendered():
|
||||
return chartwheel.render(_sample_chart())
|
||||
|
||||
|
||||
def test_output_is_well_formed_xml():
|
||||
"""Wstawiamy surowy SVG do strony — musi być poprawnym XML, bo inaczej
|
||||
zepsuje cały dokument."""
|
||||
svg = _rendered().replace("var(--line)", "#000").replace(
|
||||
"var(--accent)", "#000").replace("var(--muted)", "#000")
|
||||
doc = minidom.parseString(svg) # rzuci wyjątkiem, gdy błędny
|
||||
assert doc.documentElement.tagName == "svg"
|
||||
|
||||
|
||||
def test_has_twelve_sign_glyphs():
|
||||
assert _rendered().count('class="glyph"') == 12
|
||||
|
||||
|
||||
def test_has_all_four_axis_labels():
|
||||
svg = _rendered()
|
||||
for label in (">AC<", ">DC<", ">MC<", ">IC<"):
|
||||
assert label in svg
|
||||
|
||||
|
||||
def test_has_house_numbers_one_to_twelve():
|
||||
svg = _rendered()
|
||||
for h in range(1, 13):
|
||||
assert f">{h}<" in svg
|
||||
|
||||
|
||||
def test_ascendant_is_on_the_left():
|
||||
"""Konwencja: Asc po lewej. Sprawdzamy oś AC–DC liczbowo — punkt Asc musi mieć
|
||||
x wyraźnie mniejszy od środka (220), a Dsc większy."""
|
||||
chart = _sample_chart(asc=128.9)
|
||||
asc = chart["angles"]["Asc"]["decimal"]
|
||||
ax, ay = chartwheel._pt(asc, asc, chartwheel.R_ZOD)
|
||||
dx, dy = chartwheel._pt(asc + 180, asc, chartwheel.R_ZOD)
|
||||
assert ax < chartwheel._CX - 100, "Asc powinien być po lewej"
|
||||
assert dx > chartwheel._CX + 100, "Dsc powinien być po prawej"
|
||||
assert abs(ay - chartwheel._CY) < 1e-6, "oś Asc–Dsc jest pozioma"
|
||||
|
||||
|
||||
def test_longitude_increases_counterclockwise():
|
||||
"""Asc+90° (II dom) ląduje na DOLE koła (y > środek, bo w SVG y rośnie w dół)."""
|
||||
asc = 100.0
|
||||
_, y = chartwheel._pt(asc + 90, asc, chartwheel.R_ZOD)
|
||||
assert y > chartwheel._CY + 100
|
||||
|
||||
|
||||
def test_empty_when_no_angles():
|
||||
"""Silnik bez osi/domów (brak sidereal) → brak koła, nie wyjątek."""
|
||||
assert chartwheel.render({"positions": []}) == ""
|
||||
assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == ""
|
||||
|
||||
|
||||
def test_empty_when_cusps_lack_longitude():
|
||||
"""Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy."""
|
||||
chart = _sample_chart()
|
||||
for c in chart["cusps"]:
|
||||
del c["decimal"]
|
||||
assert chartwheel.render(chart) == ""
|
||||
Reference in New Issue
Block a user