diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx
index dff0e3f..41c59ba 100644
Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ
diff --git a/services/presentation/app/chartwheel.py b/services/presentation/app/chartwheel.py
index 9199a32..68f1611 100644
--- a/services/presentation/app/chartwheel.py
+++ b/services/presentation/app/chartwheel.py
@@ -55,17 +55,47 @@ _ELEMENT_COLOR = ["#d98a6a", "#8fae7a", "#cfc06a", "#6aa6c9"] # ogień, ziemia,
# osobnego konwertera (rsvg → PDF, PRE-24), który o naszym arkuszu nic nie wie
# i zmiennych `var(--…)` nie rozwiąże — wyszłyby czarne albo żadne kreski.
# Przy okazji odwracamy kontrast: raport drukuje się na białym papierze.
+# Kolor linii aspektu koduje jego CHARAKTER, jak w klasycznych programach:
+# niebieski = harmonijny (trygon, sekstyl), czerwony = napięty (kwadratura,
+# opozycja), zielony = koniunkcja (neutralna, wzmacnia). To pierwszy sygnał, jaki
+# łapie oko — dwa kolory na środku koła mówią „gdzie łatwo, gdzie tarcie".
+_ASPECT_COLORS_SCREEN = {
+ "conjunction": "#8fae7a", "sextile": "#6aa6c9", "trine": "#6aa6c9",
+ "square": "#cf6a6a", "opposition": "#cf6a6a",
+ "semisextile": "#6aa6c9", "semisquare": "#cf6a6a", "quincunx": "#cf6a6a",
+}
+_ASPECT_COLORS_PRINT = {
+ "conjunction": "#3f6b33", "sextile": "#1f5c80", "trine": "#1f5c80",
+ "square": "#a83232", "opposition": "#a83232",
+ "semisextile": "#1f5c80", "semisquare": "#a83232", "quincunx": "#a83232",
+}
+
_THEMES = {
"screen": {"line": "var(--line)", "ink": "var(--ink)", "muted": "var(--muted)",
"accent": "var(--accent)", "disc": "#12132a", "retro": "#ff9b6a",
- "elements": _ELEMENT_COLOR},
+ "elements": _ELEMENT_COLOR, "aspects": _ASPECT_COLORS_SCREEN},
"print": {"line": "#b9bcc9", "ink": "#111318", "muted": "#5d6270",
"accent": "#5a49c8", "disc": "#ffffff", "retro": "#b2452a",
# ciemniejsze warianty żywiołów — na białym tle jasne byłyby niewidoczne
- "elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"]},
+ "elements": ["#a8442a", "#3f6b33", "#8a6d10", "#1f5c80"],
+ "aspects": _ASPECT_COLORS_PRINT},
}
+def _aspect_pen(orb: float, allowed: float | None) -> tuple[float, float]:
+ """Grubość i przezroczystość linii aspektu wg orbu.
+
+ Im ciaśniejszy aspekt, tym mocniejsza linia — bo tym silniej działa. Poniżej
+ 1° POGRUBIAMY wyraźnie (wprost z wymagania): najściślejsze aspekty mają się
+ rzucać w oczy. Szerokie, na granicy orbu, ledwo majaczą — żeby nie zamieniać
+ środka koła w plątaninę."""
+ allowed = allowed or 8.0
+ frac = max(0.0, 1.0 - orb / allowed) # 1 = dokładny, 0 = na krawędzi orbu
+ if orb < 1.0:
+ return 2.0, 0.95 # ciasny — pogrubiony
+ return 0.7 + 1.0 * frac, 0.35 + 0.5 * frac
+
+
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)
@@ -176,6 +206,24 @@ def render(chart: dict, theme: str = "screen") -> str:
parts.append(f'')
parts.append(f'')
+ # ── linie aspektów (LOG-06) w środku koła ──────────────────────────
+ # Łączą PRAWDZIWE pozycje obiektów (nie rozsunięte glify — aspekt dotyczy
+ # tego, gdzie planeta faktycznie stoi) na okręgu piasty. Rysowane jako
+ # PIERWSZE, pod resztą: obiekty siedzą na R_PLANET=150, daleko od piasty
+ # (R_HUB=48), więc się nie stykają, a osie i szprychy lądują na wierzchu.
+ pos_by_name = {p["name"]: float(p["decimal"])
+ for p in (chart.get("positions") or []) if p.get("decimal") is not None}
+ for asp in (chart.get("aspects") or []):
+ la = pos_by_name.get(asp.get("obj1"))
+ lb = pos_by_name.get(asp.get("obj2"))
+ if la is None or lb is None:
+ continue
+ color = T["aspects"].get(asp.get("aspect"), T["muted"])
+ w, op = _aspect_pen(float(asp.get("orb", 0.0)), asp.get("allowed"))
+ ax1, ay1 = _pt(la, asc, R_HUB)
+ bx1, by1 = _pt(lb, asc, R_HUB)
+ parts.append(_line(ax1, ay1, bx1, by1, color, w, op))
+
# 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
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html
index f40473b..d4c1b1b 100644
--- a/services/presentation/app/templates/chart.html
+++ b/services/presentation/app/templates/chart.html
@@ -81,7 +81,7 @@
{% if wheel_svg %}
{{ wheel_svg | safe }}
- 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.
+ Kosmogram — znaki, domy, osie, obiekty i aspekty. Kreska na pasie wskazuje dokładną pozycję; glif bywa odsunięty, gdy obiekty stoją ciasno. Linie w środku: niebieskie — harmonijne, czerwone — napięte, grubsze = ciaśniejszy orb.
{% endif %}
diff --git a/services/presentation/tests/test_chartwheel.py b/services/presentation/tests/test_chartwheel.py
index 8ea7d5a..c5075aa 100644
--- a/services/presentation/tests/test_chartwheel.py
+++ b/services/presentation/tests/test_chartwheel.py
@@ -193,3 +193,79 @@ def test_empty_when_cusps_lack_longitude():
for c in chart["cusps"]:
del c["decimal"]
assert chartwheel.render(chart) == ""
+
+
+# ───────────────────────────── linie aspektów ────────────────────────────
+
+def _chart_with_aspects():
+ chart = _chart_with_objects()
+ chart["positions"].append({"name": "Mars", "decimal": 220.0, "glyph": "♂", "direction": "D"})
+ chart["aspects"] = [
+ {"obj1": "Sun", "obj2": "Mars", "aspect": "trine", "orb": 0.2, "allowed": 8.0},
+ {"obj1": "Moon", "obj2": "Mars", "aspect": "square", "orb": 5.0, "allowed": 8.0},
+ {"obj1": "Mercury", "obj2": "Venus", "aspect": "conjunction", "orb": 0.4, "allowed": 10.0},
+ ]
+ return chart
+
+
+def test_aspect_lines_are_drawn():
+ """Każdy aspekt, którego oba obiekty mają pozycję, dostaje linię — porównujemy
+ TEN SAM wykres z aspektami i bez, żeby różnica była wyłącznie aspektami."""
+ chart = _chart_with_aspects()
+ with_asp = chartwheel.render(chart).count("