feat(prezentacja): obiekty na kosmogramie — glify, stopnie, retrogradacja (PRE-12)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 38s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m4s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m54s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m57s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m55s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 38s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 27s
build / build (push) Successful in 1m4s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m54s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 33s
Testy / Kontrola składni wszystkich warstw (push) Successful in 25s
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 <noreply@anthropic.com>
This commit was merged in pull request #27.
This commit is contained in:
@@ -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)}</text>')
|
||||
|
||||
|
||||
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'<svg class="wheel" viewBox="0 0 440 440" role="img" '
|
||||
f'aria-label="Kosmogram — szkielet koła horoskopowego" '
|
||||
return (f'<svg class="wheel" viewBox="0 0 {_VIEW} {_VIEW}" role="img" '
|
||||
f'aria-label="Kosmogram — koło horoskopowe" '
|
||||
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
{% 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>
|
||||
<figcaption class="muted small">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.</figcaption>
|
||||
</figure>
|
||||
{% endif %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user