feat(prezentacja): wykres deklinacji i oś antyscji (LOG-07, kosmogram etap 6)

Ostatni etap rysowania kosmogramu: wizualizacje pochodne z aspektów
pozazodiakalnych (LOG-07). Do tej pory paralele i antyscja były tylko w tabelach —
teraz widać je na rzut oka.

WYKRES DEKLINACJI (`render_declination`):
- Pionowa skala deklinacji z równikiem (0°) i zwrotnikami (±ε, kreskowane) —
  ε bierzemy z `obliquity` w wyniku, więc granica jest dokładna dla daty.
- Obiekty na osi X ułożone wg POSORTOWANEJ deklinacji, więc paralele (ta sama
  wysokość) lądują obok siebie. Łączniki: paralela zielona (jak koniunkcja),
  kontrparalela czerwona (jak opozycja) — te same barwy co linie na kole, grubość
  wg orbu. Dymek z nazwą zjawiska i orbem.
- Strefa poza zwrotnikami cieniowana; obiekt OOB (out-of-bounds) w kolorze
  wyróżnienia + „OOB" w dymku. Od razu widać ciała o skrajnej deklinacji.

OŚ ANTYSCJI (`render_antiscia`):
- Ekliptyka rozwinięta w poziomą oś ze znakami; pionowo zaznaczona oś przesileń
  (0° Raka/Koziorożca) — lustro antyscji — i oś równonocy (0° Barana/Wagi) dla
  kontrantyscji. Pary połączone łukiem (zielony antyscja / czerwony kontrantyscja),
  z dymkiem. Bez par oś i obiekty i tak coś mówią.

Oba w obu motywach (screen + print), więc gotowe też do PDF-a. Pokazują się na
/chart pod odpowiednimi tabelami LOG-07. Testy: +13 (etap 6). Prezentacja: 170.

Domyka etapy kosmogramu (PRE-12): 1 szkielet, 2 obiekty, 3 aspekty, 4 dopracowanie,
5 aspektarian, 6 deklinacja/antyscja.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:17:35 +02:00
committed by gitea
parent 998c83b26e
commit adce568729
5 changed files with 298 additions and 0 deletions
+161
View File
@@ -443,3 +443,164 @@ def render_aspectarian(chart: dict, theme: str = "screen") -> str:
return (f'<svg class="aspectarian" viewBox="0 0 {dim:.1f} {dim:.1f}" role="img" '
f'aria-label="Aspektarian — siatka aspektów obiekt na obiekt" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
# ── deklinacja i antyscja (LOG-07 → kosmogram etap 6) ─────────────────────
# Polskie nazwy zjawisk pozazodiakalnych — tylko do dymków.
_OOZ_PL = {
"parallel": "paralela", "contraparallel": "kontrparalela",
"antiscion": "antyscja", "contra_antiscion": "kontrantyscja",
}
def _dashed(x1: float, y1: float, x2: float, y2: float, stroke: str,
w: float = 0.8, opacity: float = 0.9, dash: str = "4 3") -> str:
return (f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
f'stroke="{stroke}" stroke-width="{w}" opacity="{opacity}" '
f'stroke-dasharray="{dash}"/>')
_DECL_W, _DECL_H = 470.0, 300.0
_DPX0, _DPX1 = 54.0, 452.0 # obszar rysunku w poziomie
_DPY0, _DPY1 = 30.0, 262.0 # góra (+Rmax) i dół (Rmax)
def render_declination(chart: dict, theme: str = "screen") -> str:
"""Wykres deklinacji (LOG-07, etap 6). Pionowa skala deklinacji: obiekty na
tej samej wysokości są w PARALELI (działa jak koniunkcja poza ekliptyką), na
lustrzanych względem równika — w KONTRPARALELI (jak opozycja). Zwrotniki (±ε)
to granica „poza zakresem" (OOB): ciało za nimi ma większą deklinację niż
kiedykolwiek Słońce. Kolory łączników jak na kole. '' przy < 2 obiektach."""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objs = [p for p in (chart.get("positions") or []) if p.get("declination") is not None]
if len(objs) < 2:
return ""
eps = float(chart.get("obliquity") or 23.4367)
maxabs = max(abs(float(p["declination"])) for p in objs)
Rmax = max(eps + 3.0, maxabs + 2.0) # skala mieści zwrotniki i OOB
def y_of(dec: float) -> float:
return _DPY0 + (Rmax - dec) / (2.0 * Rmax) * (_DPY1 - _DPY0)
parts: list[str] = []
# strefy OOB (za zwrotnikami) — delikatne tło
yN, yS = y_of(eps), y_of(-eps)
parts.append(f'<rect x="{_DPX0:.1f}" y="{_DPY0:.1f}" width="{_DPX1 - _DPX0:.1f}" '
f'height="{yN - _DPY0:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
parts.append(f'<rect x="{_DPX0:.1f}" y="{yS:.1f}" width="{_DPX1 - _DPX0:.1f}" '
f'height="{_DPY1 - yS:.1f}" fill="{T["retro"]}" opacity="0.07"/>')
# równik (0°) i zwrotniki (±ε)
yeq = y_of(0.0)
parts.append(_line(_DPX0, yeq, _DPX1, yeq, T["line"], 1.0, 0.9))
parts.append(_text(_DPX0 - 8, yeq, "", size=8, fill=T["muted"]))
for d, lab in ((eps, f"+{eps:.0f}°"), (-eps, f"{eps:.0f}°")):
yy = y_of(d)
parts.append(_dashed(_DPX0, yy, _DPX1, yy, T["accent"], 0.8, 0.8))
parts.append(_text(_DPX0 - 8, yy, lab, size=8, fill=T["accent"]))
parts.append(_text((_DPX0 + _DPX1) / 2, 16,
"deklinacja — poza ±ε (zwrotniki) obiekt jest OOB", size=8, fill=T["muted"]))
# X równomiernie wg POSORTOWANEJ deklinacji — paralele lądują obok siebie
order = sorted(range(len(objs)), key=lambda i: float(objs[i]["declination"]))
n = len(order)
xy: dict = {}
for slot, idx in enumerate(order):
x = _DPX0 + (slot + 0.5) / n * (_DPX1 - _DPX0)
xy[objs[idx]["name"]] = (x, y_of(float(objs[idx]["declination"])))
# łączniki paralel/kontrparalel (zielony/czerwony — jak koniunkcja/opozycja)
for par in (chart.get("parallels") or []):
a = xy.get(par.get("obj1")); b = xy.get(par.get("obj2"))
if not a or not b:
continue
kind = par.get("type")
color = T["aspects"]["conjunction"] if kind == "parallel" else T["aspects"]["opposition"]
orb = float(par.get("orb", 0.0))
w, op = _aspect_pen(orb, par.get("allowed"))
tip = f'{par.get("obj1")} {_OOZ_PL.get(kind, kind)} {par.get("obj2")} · orb {orb:.2f}°'
parts.append(_line(a[0], a[1], b[0], b[1], color, w, op, title=tip))
# glify na wysokości ich deklinacji + podpis stopnia; OOB wyróżnione
for p in objs:
x, y = xy[p["name"]]
oob = bool(p.get("out_of_bounds"))
dec = float(p["declination"])
tip = f'{p.get("name")} · dekl. {dec:+.2f}°' + (" · OOB" if oob else "")
parts.append(_text(x, y, p.get("glyph") or "", size=15,
fill=T["retro"] if oob else T["ink"],
cls="glyph", font=glyph_font, title=tip))
parts.append(_text(x, y + 12, f"{dec:+.1f}", size=7,
fill=T["retro"] if oob else T["muted"]))
body = "".join(parts)
return (f'<svg class="declination" viewBox="0 0 {_DECL_W:.0f} {_DECL_H:.0f}" role="img" '
f'aria-label="Wykres deklinacji — paralele i out-of-bounds" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
_ANT_W, _ANT_H = 470.0, 150.0
_ANT_X0, _ANT_X1 = 22.0, 448.0
_ANT_Y = 100.0 # oś ekliptyki
def render_antiscia(chart: dict, theme: str = "screen") -> str:
"""Oś antyscji (LOG-07, etap 6). Ekliptyka rozwinięta w poziomą oś; pionowo
zaznaczona OŚ PRZESILEŃ (0° Raka / 0° Koziorożca) — względem niej odbija się
antyscja, a względem osi równonocy (0° Barana / Wagi) — kontrantyscja. Pary
połączone łukiem (zielony antyscja / czerwony kontrantyscja). '' przy < 2
obiektach."""
T = _THEMES.get(theme, _THEMES["screen"])
glyph_font = GLYPH_FONT if theme == "print" else ""
objs = [p for p in (chart.get("positions") or []) if p.get("decimal") is not None]
if len(objs) < 2:
return ""
def x_of(lon: float) -> float:
return _ANT_X0 + (lon % 360.0) / 360.0 * (_ANT_X1 - _ANT_X0)
parts: list[str] = []
parts.append(_line(_ANT_X0, _ANT_Y, _ANT_X1, _ANT_Y, T["line"], 1.0, 0.9))
signs = chart.get("sign_glyphs") or []
for i in range(12):
xb = x_of(i * 30.0)
parts.append(_line(xb, _ANT_Y - 5, xb, _ANT_Y + 5, T["line"], 0.6, 0.6))
if i < len(signs):
parts.append(_text(x_of(i * 30.0 + 15.0), _ANT_Y + 17, signs[i].get("glyph") or "",
size=11, fill=T["muted"], cls="glyph", font=glyph_font))
# osie odbicia: przesileń (90/270) mocno, równonocy (0/180) delikatnie
for lon, col, op in ((90.0, T["accent"], 0.9), (270.0, T["accent"], 0.9),
(0.0, T["muted"], 0.5), (180.0, T["muted"], 0.5)):
xx = x_of(lon)
parts.append(_dashed(xx, _ANT_Y - 44, xx, _ANT_Y + 24, col, 0.9, op))
parts.append(_text((_ANT_X0 + _ANT_X1) / 2, 15,
"oś przesileń — antyscja · oś równonocy — kontrantyscja",
size=8, fill=T["muted"]))
lon_by = {p["name"]: float(p["decimal"]) for p in objs}
for row in (chart.get("antiscia") or []):
la = lon_by.get(row.get("obj1")); lb = lon_by.get(row.get("obj2"))
if la is None or lb is None:
continue
kind = row.get("type")
color = T["aspects"]["conjunction"] if kind == "antiscion" else T["aspects"]["opposition"]
xa, xb = x_of(la), x_of(lb)
orb = float(row.get("orb", 0.0))
tip = f'{row.get("obj1")} {_OOZ_PL.get(kind, kind)} {row.get("obj2")} · orb {orb:.2f}°'
midx = (xa + xb) / 2.0
top = _ANT_Y - 24 - abs(xb - xa) * 0.05
parts.append(f'<path d="M {xa:.1f} {_ANT_Y - 6:.1f} Q {midx:.1f} {top:.1f} '
f'{xb:.1f} {_ANT_Y - 6:.1f}" fill="none" stroke="{color}" '
f'stroke-width="1.2" opacity="0.9"><title>{escape(tip)}</title></path>')
for p in objs:
x = x_of(float(p["decimal"]))
parts.append(_text(x, _ANT_Y - 12, p.get("glyph") or "", size=12, fill=T["ink"],
cls="glyph", font=glyph_font,
title=f'{p.get("name")} · {p.get("in_sign", "")}'.strip(" ·")))
body = "".join(parts)
return (f'<svg class="antiscia" viewBox="0 0 {_ANT_W:.0f} {_ANT_H:.0f}" role="img" '
f'aria-label="Oś antyscji — odbicia względem przesileń i równonocy" '
f'xmlns="http://www.w3.org/2000/svg">{body}</svg>')
+2
View File
@@ -97,6 +97,8 @@ def chart_compute(
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 (etap 6)
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 (etap 6)
except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e)
except ValueError as e:
@@ -57,6 +57,13 @@ svg.wheel text.glyph { font-size: inherit; }
.aspectarian-fig figcaption { margin-top: .4rem; }
svg.aspectarian text.glyph { font-size: inherit; }
/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
@@ -170,6 +170,13 @@
{% endif %}
{# Aspekty pozazodiakalne (LOG-07): paralele deklinacji i antyscja #}
{% if declination_svg %}
<figure class="declination-fig">
{{ declination_svg | safe }}
<figcaption class="muted small">Wykres deklinacji — obiekty na tej samej wysokości są w <strong>paraleli</strong> (jak koniunkcja poza ekliptyką, zielony łącznik), lustrzane względem równika — w <strong>kontrparaleli</strong> (jak opozycja, czerwony). Poza zwrotnikami (±ε, strefa cieniowana) obiekt jest <strong>OOB</strong>. Najedź po szczegóły.</figcaption>
</figure>
{% endif %}
{% if result.parallels %}
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
<table class="angles">
@@ -188,6 +195,13 @@
</table>
{% endif %}
{% if antiscia_svg %}
<figure class="antiscia-fig">
{{ antiscia_svg | safe }}
<figcaption class="muted small">Oś antyscji — ekliptyka rozwinięta w poziom; pionowa oś przesileń (0° Raka / Koziorożca) to lustro <strong>antyscji</strong>, oś równonocy (0° Barana / Wagi) — <strong>kontrantyscji</strong>. Łuki łączą pary. Najedź po szczegóły.</figcaption>
</figure>
{% endif %}
{% if result.antiscia %}
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
<table class="angles">