feat(prezentacja): szkielet kosmogramu — koło horoskopowe SVG (PRE-12) #26
@@ -106,6 +106,7 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
result["cusps"] = [
|
result["cusps"] = [
|
||||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||||
"in_sign": in_sign(norm360(c - off)),
|
"in_sign": in_sign(norm360(c - off)),
|
||||||
|
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
|
||||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||||
for i, c in enumerate(cusp_list)
|
for i, c in enumerate(cusp_list)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
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:
|
except (httpx.HTTPError,) as e:
|
||||||
ctx["error"] = _logic_error(e)
|
ctx["error"] = _logic_error(e)
|
||||||
except ValueError as 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",
|
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||||
"DejaVu Sans", sans-serif; }
|
"DejaVu Sans", sans-serif; }
|
||||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
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; }
|
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||||
.row { display: flex; gap: .5rem; }
|
.row { display: flex; gap: .5rem; }
|
||||||
.row input[type=text] { flex: 1; }
|
.row input[type=text] { flex: 1; }
|
||||||
|
|||||||
@@ -71,6 +71,13 @@
|
|||||||
{% if moment %}· moment: {{ moment }}{% endif %}
|
{% if moment %}· moment: {{ moment }}{% endif %}
|
||||||
</div>
|
</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 %}
|
{% 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>
|
||||||
|
|||||||
@@ -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