From 40c9bf7988ffb6d7b7efabe09ae94cb809def67f Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 24 Jul 2026 15:49:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(prezentacja):=20obiekty=20na=20kosmogramie?= =?UTF-8?q?=20=E2=80=94=20glify,=20stopnie,=20retrogradacja=20(PRE-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etap 2 rysowania koła (po szkielecie): obiekty na swoich pozycjach. Każdy obiekt dostaje: kreske na wewnetrznej krawedzi pasa w PRAWDZIWEJ pozycji, glif, stopien w znaku i znacznik retrogradacji ℞ (dodatkowo kolorem, zeby dalo sie ja wylapac nie czytajac znak po znaku). ROZSUWANIE CIASNYCH SKUPISK — sedno tego etapu. W horoskopie referencyjnym Merkury i Wenus dzieli 0,41°, Wenus i Ksiezyc 3,68°: bez rozsuwania glify rysuja sie jeden na drugim. Rozsuwamy tylko GLIFY; kreska zostaje w prawdziwej pozycji, a gdy glif jest odsuniety, laczymy je cienka linia odniesienia — wykres nie moze klamac o tym, gdzie planeta faktycznie stoi. Bledy zlapane przy weryfikacji (oba wyszly z pomiarow, nie z „wyglada dobrze"): 1. PODPISY STOPNI zlewaly sie w skupiskach. O ciasnocie decyduje nie glif, tylko podpis — lezy blizej srodka (r=130), gdzie ten sam kat to mniej pikseli. Stad odstep 8° zamiast 7°, podpis bez „°" (jak w programach astrologicznych) i mniejszy font. Teraz: glify min 20,9 px, podpisy 18,1 px (prog 16). 2. ROZSUWANIE NIE DZIALALO na prawdziwych danych — Wenus ladowala DOKLADNIE na Ksiezycu (0,3 px). Przyczyna: odstep liczony modulo 360. Przesuniecie, ktore przerzucalo obiekt ZA sasiada, dawalo luke ~359,9° zamiast ujemnej, wiec algorytm uznawal, ze jest luzem, i konczyl. Poprawka: rozwijamy katy do osi MONOTONICZNEJ, gdzie ujemna luka zostaje ujemna i zawsze sie ja wylapie. Test regresyjny na dokladnie tych danych; sprawdzony sabotazem (po przywroceniu modulo czerwienieje). Etykiety osi (AC/DC/MC/IC) przeniesione POZA kolo — w srodku wchodzily w pierscien obiektow i zaslanialy glify (Ksiezyc znikal pod linia MC). ViewBox 440→470, kolo bez zmian, margines mieści etykiety. Testy: 11 nowych (regresja rozsuwania, zachowanie kolejnosci, obiekty bez kolizji nieruszone, zawiniecie przez 0°, awaryjny rowny rozklad, stopien w znaku, retrogradacja, niekompletny obiekt nie wywala rysunku). Calosc: prezentacja 44, logika 265 / 1 skip. Potwierdzone wizualnie: cale skupisko Slonce/Ksiezyc/Wenus/ Merkury czytelne i rozdzielone. Co-Authored-By: Claude Opus 4.8 --- services/presentation/app/chartwheel.py | 127 ++++++++++++++++-- .../presentation/app/templates/chart.html | 2 +- .../presentation/tests/test_chartwheel.py | 107 ++++++++++++++- 3 files changed, 223 insertions(+), 13 deletions(-) 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'{body}') 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()