diff --git a/services/logic/app/engine/chart.py b/services/logic/app/engine/chart.py
index 6d84699..a5658a8 100644
--- a/services/logic/app/engine/chart.py
+++ b/services/logic/app/engine/chart.py
@@ -106,6 +106,7 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(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))])}
for i, c in enumerate(cusp_list)
]
diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py
new file mode 100644
index 0000000..74a1b79
--- /dev/null
+++ b/services/presentation/app/chartwheel.py
@@ -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'')
+
+
+def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "") -> str:
+ c = f' class="{cls}"' if cls else ""
+ return (f'{escape(s)}')
+
+
+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'')
+ parts.append(f'')
+ parts.append(f'')
+
+ # 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'')
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 3e13ffd..1d06ee4 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -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:
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
index a089f20..c9c7d94 100644
--- a/services/presentation/app/static/styles.css
+++ b/services/presentation/app/static/styles.css
@@ -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; }
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html
index deeccf0..040bf28 100644
--- a/services/presentation/app/templates/chart.html
+++ b/services/presentation/app/templates/chart.html
@@ -71,6 +71,13 @@
{% if moment %}· moment: {{ moment }}{% endif %}
+ {% if wheel_svg %}
+
+ {{ wheel_svg | safe }}
+ Kosmogram — szkielet (znaki, domy, osie). Planety i aspekty w kolejnych iteracjach.
+
+ {% endif %}
+
{% if result.angles %}
| Oś | Znak | W znaku |
diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py
new file mode 100644
index 0000000..29587d8
--- /dev/null
+++ b/services/presentation/tests/test_chartwheel.py
@@ -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) == ""