diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py
index 74a1b79..0666d54 100644
--- a/services/presentation/app/chartwheel.py
+++ b/services/presentation/app/chartwheel.py
@@ -1,8 +1,9 @@
-"""Kosmogram — szkielet koła horoskopowego jako SVG (PRE-12).
+"""Kosmogram — koło horoskopowe 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.
+string SVG wstawiany do strony. Gotowe: pierścień znaków, podział na domy, osie
+oraz OBIEKTY (glify, stopień w znaku, retrogradacja). Linie aspektów w kolejnym
+kroku.
Geometria (konwencja astrologiczna):
* Ascendent po LEWEJ (godzina 9), długość ekliptyczna rośnie PRZECIWNIE do
@@ -20,14 +21,30 @@ from __future__ import annotations
import math
from html import escape
-# ── promienie (viewBox 440×440, środek 220,220) ─────────────────────────
-_CX = _CY = 220.0
+# ── promienie (viewBox 470×470, środek 235,235) ─────────────────────────
+# Płótno jest większe od koła CELOWO: margines poza obwodem mieści etykiety osi
+# (AC/DC/MC/IC). Wcześniej siedziały wewnątrz, w pierścieniu obiektów, i zasłaniały
+# glify w ciasnych skupiskach — widać było na renderze.
+_CX = _CY = 235.0
+_VIEW = 470
R_OUT = 210.0 # zewnętrzny okrąg
+R_AXIS_LABEL = 222.0 # etykiety osi — w marginesie, poza kołem
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)
+R_PLANET = 150.0 # promień glifów obiektów
+R_DEG = 130.0 # promień podpisu stopnia (pod glifem)
+R_MARK = R_ZOD - 7.0 # koniec kreski wskazującej PRAWDZIWĄ pozycję na pasie
+
+# Minimalny odstęp kątowy między glifami obiektów. O ciasnocie decyduje NIE glif,
+# tylko podpis stopnia — leży bliżej środka (r=130), gdzie ten sam kąt to mniej
+# pikseli: 8° daje tam ~18 px, a dwuznakowy podpis z ℞ zajmuje ~16 px. Przy 7°
+# (pierwsze podejście) podpisy w skupiskach zlewały się — widać było na renderze.
+# Bez rozsuwania w ogóle ciasne układy (u nas Merkury i Wenus 0,4°!) rysują się
+# jeden na drugim.
+MIN_SEP = 8.0
# muted kolory żywiołów — czytelne na ciemnym tle, spójne z paletą aplikacji
_ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia, powietrze, woda
@@ -50,6 +67,65 @@ def _text(x: float, y: float, s: str, *, size: float, fill: str, cls: str = "")
f' text-anchor="middle" dominant-baseline="central"{c}>{escape(s)}')
+def spread(lons: list[float], min_sep: float = MIN_SEP, passes: int = 300) -> list[float]:
+ """Rozsuwa kąty tak, by sąsiedzi mieli co najmniej `min_sep`, zachowując
+ KOLEJNOŚĆ obiektów na kole i trzymając je blisko prawdziwych pozycji.
+
+ Metoda: relaksacja po okręgu — każdą zbyt ciasną parę rozpychamy po połowie
+ brakującego odstępu i powtarzamy, aż nic się nie rusza. Symetryczne pchnięcie
+ sprawia, że skupisko rozkłada się wokół swojego środka, zamiast dryfować
+ w jedną stronę.
+
+ Gdy obiektów jest tyle, że nie da się ich rozsunąć (n·min_sep ≥ 360°),
+ rozkładamy je równomiernie — lepiej równo niż w nieskończonej pętli.
+ """
+ n = len(lons)
+ if n < 2:
+ return list(lons)
+ order = sorted(range(n), key=lambda i: lons[i] % 360.0)
+ a = [lons[i] % 360.0 for i in order]
+
+ if n * min_sep >= 360.0:
+ a = [(a[0] + i * 360.0 / n) % 360.0 for i in range(n)]
+ else:
+ # ROZWIJAMY kąty do osi monotonicznej. To nie kosmetyka: gdy liczyliśmy
+ # odstęp modulo 360, przesunięcie, które przerzuciło obiekt ZA sąsiada,
+ # dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że jest luzem,
+ # i kończył z dwoma glifami na sobie (Wenus lądowała na Księżycu).
+ # Na osi rozwiniętej ujemna luka zostaje ujemna i zawsze się ją wyłapie.
+ for i in range(1, n):
+ while a[i] < a[i - 1]:
+ a[i] += 360.0
+
+ for _ in range(passes):
+ moved = False
+ for i in range(n - 1):
+ gap = a[i + 1] - a[i]
+ if gap < min_sep - 1e-9:
+ push = (min_sep - gap) / 2.0
+ a[i] -= push
+ a[i + 1] += push
+ moved = True
+ closing = (a[0] + 360.0) - a[n - 1] # domknięcie okręgu
+ if closing < min_sep - 1e-9:
+ push = (min_sep - closing) / 2.0
+ a[n - 1] -= push
+ a[0] += push
+ moved = True
+ if not moved:
+ break
+
+ out = [0.0] * n
+ for slot, original in enumerate(order):
+ out[original] = a[slot] % 360.0
+ return out
+
+
+def _delta(a: float, b: float) -> float:
+ """Najmniejsza różnica kątów w [0,180]."""
+ return abs(((a - b + 180.0) % 360.0) - 180.0)
+
+
def render(chart: dict) -> str:
"""Zwraca SVG koła albo '' gdy brakuje danych (silnik bez osi/domów)."""
angles = chart.get("angles")
@@ -102,13 +178,44 @@ def render(chart: dict) -> str:
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)
+ # etykiety POZA kołem — w środku kolidowały z glifami obiektów
+ lax, lay = _pt(lon_a, asc, R_AXIS_LABEL)
+ lbx, lby = _pt(lon_a + 180.0, asc, R_AXIS_LABEL)
parts.append(_text(lax, lay, la, size=10, fill="var(--accent)"))
parts.append(_text(lbx, lby, lb, size=10, fill="var(--accent)"))
+ # ── obiekty: glify na pozycjach, stopień w znaku, retrogradacja ─────
+ # Glify rozsuwamy, ale kreska na pasie zostaje w PRAWDZIWEJ pozycji —
+ # inaczej wykres kłamałby o tym, gdzie planeta faktycznie stoi. Gdy glif
+ # jest przesunięty, łączymy go z kreską cienką linią odniesienia.
+ objects = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
+ true_lons = [float(p["decimal"]) for p in objects]
+ draw_lons = spread(true_lons)
+
+ for obj, true_lon, draw_lon in zip(objects, true_lons, draw_lons):
+ retro = obj.get("direction") == "Rx"
+
+ mx1, my1 = _pt(true_lon, asc, R_ZOD)
+ mx2, my2 = _pt(true_lon, asc, R_MARK)
+ parts.append(_line(mx1, my1, mx2, my2, "var(--ink)", 1.0, 0.75))
+
+ if _delta(draw_lon, true_lon) > 0.4:
+ lx, ly = _pt(draw_lon, asc, R_PLANET + 12)
+ parts.append(_line(mx2, my2, lx, ly, "var(--line)", 0.7, 0.9))
+
+ gx, gy = _pt(draw_lon, asc, R_PLANET)
+ parts.append(_text(gx, gy, obj.get("glyph") or "", size=16,
+ fill="var(--ink)", cls="glyph"))
+
+ # Sam stopień w znaku, bez „°" — tak robią programy astrologiczne i tylko
+ # tak podpisy mieszczą się obok siebie w skupiskach. Retrogradacja: ℞ oraz
+ # kolor, żeby dało się ją wyłapać nie czytając znaku po znaku.
+ dx, dy = _pt(draw_lon, asc, R_DEG)
+ label = f"{int(true_lon % 30)}" + ("℞" if retro else "")
+ parts.append(_text(dx, dy, label, size=8,
+ fill="#ff9b6a" if retro else "var(--muted)"))
+
body = "".join(parts)
- return (f'')
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html
index 040bf28..b698146 100644
--- a/services/presentation/app/templates/chart.html
+++ b/services/presentation/app/templates/chart.html
@@ -74,7 +74,7 @@
{% if wheel_svg %}
{{ wheel_svg | safe }}
- Kosmogram — szkielet (znaki, domy, osie). Planety i aspekty w kolejnych iteracjach.
+ Kosmogram — znaki, domy, osie i obiekty. Kreska na pasie wskazuje dokładną pozycję; glif bywa odsunięty, gdy obiekty stoją ciasno. Linie aspektów w kolejnym kroku.
{% endif %}
diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py
index 29587d8..8ea7d5a 100644
--- a/services/presentation/tests/test_chartwheel.py
+++ b/services/presentation/tests/test_chartwheel.py
@@ -1,9 +1,11 @@
-"""Szkielet kosmogramu — SVG po stronie serwera (PRE-12).
+"""Kosmogram — koło horoskopowe jako SVG (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.
+osie, numery domów i obiekty. Geometria (Asc po lewej) oraz rozsuwanie ciasnych
+skupisk sprawdzone liczbowo, nie na oko.
"""
+import pytest
import xml.dom.minidom as minidom
from app import chartwheel
@@ -84,6 +86,107 @@ def test_empty_when_no_angles():
assert chartwheel.render({"angles": {}, "cusps": [], "sign_glyphs": []}) == ""
+# ───────────────────────── rozsuwanie nakładających się obiektów ─────────
+
+# Prawdziwe długości z horoskopu referencyjnego (1984-04-30, Kraków), w kolejności
+# DEFAULT_OBJECTS. Merkury i Wenus dzieli 0,41°, Wenus i Księżyc 3,68° — bez
+# rozsuwania glify rysują się jeden na drugim.
+_REAL = [40.210, 31.449, 27.355, 27.767, 234.520, 282.962, 223.306,
+ 252.811, 271.220, 210.477, 68.158, 248.158, 345.667]
+
+
+def _min_gap(angles):
+ s = sorted(a % 360.0 for a in angles)
+ return min((s[(i + 1) % len(s)] - s[i]) % 360.0 for i in range(len(s)))
+
+
+def test_spread_separates_real_chart_cluster():
+ """REGRESJA: przy liczeniu odstępu modulo 360 przesunięcie, które przerzucało
+ obiekt ZA sąsiada, dawało lukę ~359,9° zamiast ujemnej — algorytm uznawał, że
+ jest luzem, i kończył z Wenus dokładnie na Księżycu (0,3 px od siebie)."""
+ out = chartwheel.spread(_REAL)
+ assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
+
+
+def test_spread_preserves_order():
+ out = chartwheel.spread(_REAL)
+ by_new = [i for i, _ in sorted(enumerate(out), key=lambda p: p[1])]
+ by_old = [i for i, _ in sorted(enumerate(_REAL), key=lambda p: p[1] % 360.0)]
+ assert by_new == by_old
+
+
+def test_spread_leaves_roomy_objects_untouched():
+ """Obiekty, które nikomu nie wchodzą w drogę, mają zostać na swoim miejscu —
+ wykres ma kłamać jak najmniej."""
+ out = chartwheel.spread(_REAL)
+ node = _REAL.index(68.158) # Węzeł Płn. — 28° od najbliższego
+ assert out[node] == pytest.approx(_REAL[node], abs=1e-6)
+
+
+def test_spread_handles_wrap_around_zero():
+ """Skupisko na styku 0/360° też musi się rozsunąć."""
+ out = chartwheel.spread([358.0, 359.0, 1.0, 2.0])
+ assert _min_gap(out) >= chartwheel.MIN_SEP - 1e-6
+
+
+def test_spread_falls_back_to_even_layout_when_impossible():
+ """Gdy obiektów jest tyle, że min. odstęp się nie zmieści — rozkładamy równo,
+ zamiast kręcić się w pętli."""
+ many = [10.0] * 60 # 60 × 8° = 480° > 360°
+ out = chartwheel.spread(many)
+ assert _min_gap(out) == pytest.approx(360.0 / 60, abs=1e-6)
+
+
+def test_spread_single_object_is_identity():
+ assert chartwheel.spread([42.0]) == [42.0]
+
+
+# ─────────────────────────────── obiekty na kole ─────────────────────────
+
+def _chart_with_objects():
+ chart = _sample_chart()
+ chart["positions"] = [
+ {"name": "Sun", "decimal": 40.21, "glyph": "☉", "direction": "D"},
+ {"name": "Moon", "decimal": 31.45, "glyph": "☽", "direction": "D"},
+ {"name": "Venus", "decimal": 27.77, "glyph": "♀", "direction": "D"},
+ {"name": "Mercury", "decimal": 27.36, "glyph": "☿", "direction": "Rx"},
+ ]
+ return chart
+
+
+def test_all_objects_are_drawn():
+ svg = chartwheel.render(_chart_with_objects())
+ for glyph in ("☉", "☽", "♀", "☿"):
+ assert glyph in svg
+
+
+def test_degree_in_sign_is_labelled():
+ """Stopień W ZNAKU (nie długość absolutna): Słońce 40,21° → 10 Byka."""
+ svg = chartwheel.render(_chart_with_objects())
+ assert ">10<" in svg # Słońce
+ assert ">1<" in svg # Księżyc (31,45 → 1 Byka)
+
+
+def test_retrograde_is_marked():
+ svg = chartwheel.render(_chart_with_objects())
+ assert "℞" in svg
+
+
+def test_no_retrograde_marker_when_all_direct():
+ chart = _chart_with_objects()
+ for p in chart["positions"]:
+ p["direction"] = "D"
+ assert "℞" not in chartwheel.render(chart)
+
+
+def test_objects_without_longitude_are_skipped():
+ """Niekompletny obiekt nie może wywalić całego rysunku."""
+ chart = _chart_with_objects()
+ chart["positions"].append({"name": "Broken", "glyph": "?"})
+ svg = chartwheel.render(chart)
+ assert svg and "☉" in svg
+
+
def test_empty_when_cusps_lack_longitude():
"""Starsze wyniki bez `decimal` w cuspach — degradujemy do pustego, nie sypiemy."""
chart = _sample_chart()