Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ace76183e5 |
Binary file not shown.
@@ -36,6 +36,7 @@ def _shift_pos(pdict: dict, off: float) -> None:
|
||||
|
||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||
from app.engine import glyphs as GL
|
||||
from app.engine.aspects import find_aspects
|
||||
from app.engine.houses import mean_obliquity
|
||||
from app.engine.out_of_zodiac import (
|
||||
@@ -64,6 +65,10 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
result["parallels"] = find_declination_aspects(result["positions"])
|
||||
result["antiscia"] = find_antiscia(result["positions"])
|
||||
|
||||
# glify aspektów (LOG-22) — symbol aspektu nie zależy od zodiaku
|
||||
for a in result["aspects"]:
|
||||
a["glyph"] = GL.aspect_glyph(a["aspect"])
|
||||
|
||||
# offset zodiaku (LOG-04): syderyczny = ayanamsa, draconic = długość węzła
|
||||
node_lon = next((p.longitude for p in positions if p.name == "North Node"), None)
|
||||
off = Z.offset(zodiac, Z.julian_day(moment.when_utc), node_lon)
|
||||
@@ -73,6 +78,13 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
for pdict in result["positions"]:
|
||||
_shift_pos(pdict, off)
|
||||
|
||||
# glify obiektów (LOG-22): symbol planety jest niezmienniczy, symbol ZNAKU
|
||||
# zależy od zodiaku, więc po przesunięciu. Pierścień 12 znaków pod kosmogram.
|
||||
for pdict in result["positions"]:
|
||||
pdict["glyph"] = GL.glyph_for(pdict["name"])
|
||||
pdict["sign_glyph"] = GL.sign_glyph(pdict["sign"])
|
||||
result["sign_glyphs"] = [{"sign": s, "glyph": GL.sign_glyph(s)} for s in SIGNS]
|
||||
|
||||
if not hasattr(engine, "sidereal"):
|
||||
return result
|
||||
|
||||
@@ -89,9 +101,12 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||
}
|
||||
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||
result["cusps"] = [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||
"in_sign": in_sign(norm360(c - off))}
|
||||
"in_sign": in_sign(norm360(c - off)),
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||
for i, c in enumerate(cusp_list)
|
||||
]
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
@@ -110,7 +125,9 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
"longitude": decimal(norm360(lot["longitude"] - off)), # w wybranym zodiaku
|
||||
"sign": SIGNS[sign_index(norm360(lot["longitude"] - off))],
|
||||
"in_sign": in_sign(norm360(lot["longitude"] - off)),
|
||||
"house": H.assign_house(lot["longitude"], cusp_list)} # dom po długości tropikalnej
|
||||
"house": H.assign_house(lot["longitude"], cusp_list), # dom po długości tropikalnej
|
||||
"glyph": GL.glyph_for(lot["name"]), # ⊗ dla Fortuny; reszta None (LOG-22)
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(lot["longitude"] - off))])}
|
||||
for lot in compute_lots(pts, day, lots_method)
|
||||
]
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||
|
||||
Litery i słowa (Sa Pis 26°08' conj Fortune) ↔ glify (♄ ♓ 26°08' ☌ ⊗). Potrzebne
|
||||
pod rysowanie kosmogramu (PRE-12) — planety i znaki na kole rysujemy symbolami.
|
||||
|
||||
DWIE zasady z bazy wymagań, obie krytyczne:
|
||||
* DAN-18: TYLKO tekstowy Unicode, NIGDY emoji. Część znaków (zodiak, ♀, ♂) ma
|
||||
domyślnie prezentację emoji — kolorowy kwadrat zamiast czarno-białego glifu,
|
||||
nieczytelny i niesterowalny przez CSS. Wymuszamy prezentację tekstową
|
||||
selektorem wariantu U+FE0E (NIE U+FE0F, który robi odwrotnie).
|
||||
* DAN-17: łańcuch musi znosić dowolny Unicode. Reverse (symbol→tekst)
|
||||
normalizuje wejście, zdejmując selektory wariantu, żeby glif z FE0E i bez
|
||||
dawał ten sam wynik.
|
||||
|
||||
Glify zapisane przez \\u — jednoznaczne code-pointy, odporne na zniekształcenia
|
||||
edytorów i samodokumentujące (widać, który to znak Unicode).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# selektory wariantu prezentacji
|
||||
_TEXT = "︎" # VS15 — wymusza glif tekstowy (czarno-biały)
|
||||
_EMOJI = "️" # VS16 — prezentacja emoji; NIGDY nie emitujemy, zdejmujemy przy reverse
|
||||
|
||||
|
||||
def _t(cp: str) -> str:
|
||||
"""Znak z wymuszoną prezentacją tekstową (dokleja VS15)."""
|
||||
return cp + _TEXT
|
||||
|
||||
|
||||
# ── planety i światła ────────────────────────────────────────────────────
|
||||
# ♀ i ♂ mają wariant emoji → wymuszamy tekst; reszta jest tekstowa domyślnie.
|
||||
PLANET = {
|
||||
"Sun": "☉", # ☉
|
||||
"Moon": "☽", # ☽
|
||||
"Mercury": "☿", # ☿
|
||||
"Venus": _t("♀"), # ♀︎
|
||||
"Mars": _t("♂"), # ♂︎
|
||||
"Jupiter": "♃", # ♃
|
||||
"Saturn": "♄", # ♄
|
||||
"Uranus": "♅", # ♅
|
||||
"Neptune": "♆", # ♆
|
||||
"Pluto": "♇", # ♇
|
||||
}
|
||||
|
||||
# ── punkty wirtualne ─────────────────────────────────────────────────────
|
||||
POINT = {
|
||||
"North Node": "☊", # ☊
|
||||
"South Node": "☋", # ☋
|
||||
"Lilith": "⚸", # ⚸ (Black Moon Lilith)
|
||||
"Chiron": "⚷", # ⚷
|
||||
}
|
||||
|
||||
# ── Lots (punkty arabskie) ───────────────────────────────────────────────
|
||||
# Standardowy glif ma tylko Fortuna (⊗). Reszta Lotów nie ma powszechnie
|
||||
# wspieranego symbolu → zwracamy None i UI pokazuje nazwę.
|
||||
LOT = {
|
||||
"Fortune": "⊗", # ⊗
|
||||
"Part of Fortune": "⊗",
|
||||
}
|
||||
|
||||
# ── znaki zodiaku (wszystkie mają wariant emoji → wszystkie z VS15) ──────
|
||||
_SIGN_CP = {
|
||||
"Aries": "♈", "Taurus": "♉", "Gemini": "♊", "Cancer": "♋",
|
||||
"Leo": "♌", "Virgo": "♍", "Libra": "♎", "Scorpio": "♏",
|
||||
"Sagittarius": "♐", "Capricorn": "♑", "Aquarius": "♒", "Pisces": "♓",
|
||||
}
|
||||
SIGN = {name: _t(cp) for name, cp in _SIGN_CP.items()}
|
||||
|
||||
# ── aspekty (nazwy jak w aspects.py + minor z abbreviations) ─────────────
|
||||
ASPECT = {
|
||||
"conjunction": "☌", # ☌
|
||||
"opposition": "☍", # ☍
|
||||
"trine": "△", # △
|
||||
"square": "□", # □
|
||||
"sextile": "⚹", # ⚹
|
||||
"semisextile": "⚺", # ⚺
|
||||
"quincunx": "⚻", # ⚻
|
||||
"semisquare": "∠", # ∠
|
||||
}
|
||||
|
||||
# ── ruch ─────────────────────────────────────────────────────────────────
|
||||
RETROGRADE = "℞" # ℞
|
||||
DIRECT = "D" # zwykłe „D" — brak dedykowanego glifu prostego ruchu
|
||||
|
||||
|
||||
# ── forward: nazwa → glif ────────────────────────────────────────────────
|
||||
|
||||
def glyph_for(name: str) -> str | None:
|
||||
"""Glif obiektu/punktu/Lota po nazwie (jak w silniku). None, gdy brak."""
|
||||
return PLANET.get(name) or POINT.get(name) or LOT.get(name)
|
||||
|
||||
|
||||
def sign_glyph(sign: str) -> str | None:
|
||||
return SIGN.get(sign)
|
||||
|
||||
|
||||
def aspect_glyph(aspect: str) -> str | None:
|
||||
return ASPECT.get(aspect)
|
||||
|
||||
|
||||
# ── reverse: glif → nazwa ────────────────────────────────────────────────
|
||||
|
||||
def _strip_variants(g: str) -> str:
|
||||
"""Zdejmuje selektory wariantu — glif z FE0E i bez daje ten sam klucz."""
|
||||
return g.replace(_TEXT, "").replace(_EMOJI, "")
|
||||
|
||||
|
||||
def _reverse(mapping: dict[str, str]) -> dict[str, str]:
|
||||
# pierwsze wystąpienie wygrywa (Fortune vs Part of Fortune → 'Fortune')
|
||||
out: dict[str, str] = {}
|
||||
for name, g in mapping.items():
|
||||
out.setdefault(_strip_variants(g), name)
|
||||
return out
|
||||
|
||||
|
||||
_PLANET_POINT_REV = _reverse({**PLANET, **POINT, **{"Fortune": LOT["Fortune"]}})
|
||||
_SIGN_REV = _reverse(SIGN)
|
||||
_ASPECT_REV = _reverse(ASPECT)
|
||||
|
||||
|
||||
def name_for_glyph(g: str) -> str | None:
|
||||
"""Obiekt/punkt po glifie (znosi obecność lub brak selektora wariantu)."""
|
||||
return _PLANET_POINT_REV.get(_strip_variants(g))
|
||||
|
||||
|
||||
def sign_for_glyph(g: str) -> str | None:
|
||||
return _SIGN_REV.get(_strip_variants(g))
|
||||
|
||||
|
||||
def aspect_for_glyph(g: str) -> str | None:
|
||||
return _ASPECT_REV.get(_strip_variants(g))
|
||||
|
||||
|
||||
# ── glifikacja tekstu sygnifikatora (składnia [XX z bazy) ────────────────
|
||||
# Nasze sygnifikatory zapisane są tokenami z prefiksem [ (np. „[Sa [conj [PF").
|
||||
# glyphify zamienia znane tokeny na glify, resztę zostawia. To druga strona
|
||||
# abbreviations.expand: tam token → słowo, tu token → symbol.
|
||||
import re as _re
|
||||
|
||||
# token po nawiasie (np. „Su", „Tau", „conj", „PF") → glif
|
||||
_TOKEN_GLYPH: dict[str, str] = {}
|
||||
_ABBR_TO_NAME = {
|
||||
"Su": "Sun", "Mo": "Moon", "Me": "Mercury", "Ve": "Venus", "Ma": "Mars",
|
||||
"Ju": "Jupiter", "Sa": "Saturn", "Ur": "Uranus", "Ne": "Neptune", "Pl": "Pluto",
|
||||
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||
"PF": "Fortune", "Fortune": "Fortune",
|
||||
}
|
||||
for _abbr, _name in _ABBR_TO_NAME.items():
|
||||
_g = glyph_for(_name)
|
||||
if _g:
|
||||
_TOKEN_GLYPH[_abbr] = _g
|
||||
_SIGN_ABBR_NAME = {
|
||||
"Ari": "Aries", "Tau": "Taurus", "Gem": "Gemini", "Can": "Cancer", "Leo": "Leo",
|
||||
"Vir": "Virgo", "Lib": "Libra", "Sco": "Scorpio", "Sag": "Sagittarius",
|
||||
"Cap": "Capricorn", "Aqu": "Aquarius", "Pis": "Pisces",
|
||||
}
|
||||
for _abbr, _name in _SIGN_ABBR_NAME.items():
|
||||
_TOKEN_GLYPH[_abbr] = SIGN[_name]
|
||||
_ASP_ABBR_NAME = {
|
||||
"conj": "conjunction", "opp": "opposition", "tri": "trine", "sq": "square",
|
||||
"sex": "sextile", "semisex": "semisextile", "quincunx": "quincunx", "semisq": "semisquare",
|
||||
}
|
||||
for _abbr, _name in _ASP_ABBR_NAME.items():
|
||||
if _name in ASPECT:
|
||||
_TOKEN_GLYPH[_abbr] = ASPECT[_name]
|
||||
|
||||
_TOKEN_RE = _re.compile(r"\[([A-Za-z]+)")
|
||||
|
||||
|
||||
def glyphify(text: str) -> str:
|
||||
"""Zamienia tokeny [XX na glify; nieznane zostawia bez zmiany. Dodatkowo
|
||||
»Rx«/»R« → ℞. Nie parsuje stopni — te i tak są czytelne (26°08')."""
|
||||
if not text:
|
||||
return text
|
||||
out = _TOKEN_RE.sub(lambda m: _TOKEN_GLYPH.get(m.group(1), m.group(0)), text)
|
||||
out = _re.sub(r"\bR[x]?\b", RETROGRADE, out)
|
||||
return out
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Konwersja tekst ↔ symbol astrologiczny (LOG-22).
|
||||
|
||||
Sedno: glify muszą być KOMPLETNE (każdy obiekt/znak/aspekt, który liczymy, ma
|
||||
symbol), DWUKIERUNKOWE (symbol → tekst z powrotem) i TEKSTOWE, nigdy emoji
|
||||
(DAN-18) — inaczej na kosmogramie wyjdą kolorowe kwadraty zamiast czarno-białych
|
||||
symboli.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app.engine import glyphs as G
|
||||
from app.engine.aspects import MAJOR
|
||||
from app.engine.formats import SIGNS
|
||||
from app.engine.models import DEFAULT_OBJECTS
|
||||
|
||||
VS15, VS16 = "︎", "️"
|
||||
|
||||
|
||||
def _all_glyphs():
|
||||
return (list(G.PLANET.values()) + list(G.POINT.values()) + list(G.LOT.values())
|
||||
+ list(G.SIGN.values()) + list(G.ASPECT.values()) + [G.RETROGRADE])
|
||||
|
||||
|
||||
# ------------------------------------------------------------- kompletność
|
||||
|
||||
def test_every_default_object_has_a_glyph():
|
||||
"""Każdy obiekt z kanonicznego zestawu musi mieć symbol — inaczej na kole
|
||||
zostanie dziura."""
|
||||
missing = [name for name in DEFAULT_OBJECTS if G.glyph_for(name) is None]
|
||||
assert not missing, f"obiekty bez glifu: {missing}"
|
||||
|
||||
|
||||
def test_every_sign_has_a_glyph():
|
||||
missing = [s for s in SIGNS if G.sign_glyph(s) is None]
|
||||
assert not missing, f"znaki bez glifu: {missing}"
|
||||
|
||||
|
||||
def test_every_major_aspect_has_a_glyph():
|
||||
missing = [a for a in MAJOR if G.aspect_glyph(a) is None]
|
||||
assert not missing, f"aspekty główne bez glifu: {missing}"
|
||||
|
||||
|
||||
def test_fortune_and_retrograde_from_requirement():
|
||||
"""Wymóg wprost wymienia ⊗ (Fortuna) i ℞ (retrogradacja)."""
|
||||
assert G.glyph_for("Fortune") == "⊗"
|
||||
assert G.RETROGRADE == "℞"
|
||||
|
||||
|
||||
# ------------------------------------------------------ DAN-18: tekst, nie emoji
|
||||
|
||||
def test_no_glyph_uses_emoji_variation_selector():
|
||||
"""VS16 (U+FE0F) wymusiłby prezentację emoji — nie może go być NIGDZIE."""
|
||||
offenders = [g for g in _all_glyphs() if VS16 in g]
|
||||
assert not offenders, f"glify z wariantem emoji (FE0F): {offenders!r}"
|
||||
|
||||
|
||||
def test_signs_force_text_presentation():
|
||||
"""Znaki zodiaku mają domyślnie wariant emoji → MUSZĄ nieść VS15."""
|
||||
for s in SIGNS:
|
||||
assert VS15 in G.sign_glyph(s), f"{s} bez wymuszenia tekstu (VS15)"
|
||||
|
||||
|
||||
def test_venus_mars_force_text_but_sun_does_not():
|
||||
"""♀/♂ mają wariant emoji (VS15 konieczny); ☉ nie ma (VS15 zbędny)."""
|
||||
assert VS15 in G.glyph_for("Venus")
|
||||
assert VS15 in G.glyph_for("Mars")
|
||||
assert VS15 not in G.glyph_for("Sun") # bez zaśmiecania niepotrzebnym selektorem
|
||||
|
||||
|
||||
# ---------------------------------------------------------- dwukierunkowość
|
||||
|
||||
def test_object_round_trip():
|
||||
for name in DEFAULT_OBJECTS:
|
||||
assert G.name_for_glyph(G.glyph_for(name)) == name
|
||||
|
||||
|
||||
def test_sign_round_trip():
|
||||
for s in SIGNS:
|
||||
assert G.sign_for_glyph(G.sign_glyph(s)) == s
|
||||
|
||||
|
||||
def test_aspect_round_trip():
|
||||
for a in MAJOR:
|
||||
assert G.aspect_for_glyph(G.aspect_glyph(a)) == a
|
||||
|
||||
|
||||
def test_reverse_tolerates_missing_variant_selector():
|
||||
"""Symbol wklejony bez VS15 też musi się rozpoznać — bo z zewnątrz przyjdzie
|
||||
dowolna forma (DAN-17: łańcuch znosi każdy Unicode)."""
|
||||
assert G.sign_for_glyph("♓") == "Pisces" # ♓ bez VS15
|
||||
assert G.sign_for_glyph("♓" + VS15) == "Pisces" # ♓ z VS15
|
||||
assert G.name_for_glyph("♀") == "Venus" # ♀ bez VS15
|
||||
|
||||
|
||||
# --------------------------------------------------------- glifikacja tekstu
|
||||
|
||||
def test_glyphify_matches_requirement_example():
|
||||
"""Przykład z wymagań: [Sa [Pis 26°08' [conj [PF -> ♄ ♓ 26°08' ☌ ⊗."""
|
||||
out = G.glyphify("[Sa [Pis 26°08' [conj [PF")
|
||||
assert G.glyph_for("Saturn") in out
|
||||
assert G.sign_glyph("Pisces") in out
|
||||
assert G.aspect_glyph("conjunction") in out
|
||||
assert G.glyph_for("Fortune") in out
|
||||
assert "26°08'" in out # stopnie nietknięte
|
||||
|
||||
|
||||
def test_glyphify_handles_retrograde_and_leaves_unknown():
|
||||
out = G.glyphify("[Ma Rx w [Xyz")
|
||||
assert G.glyph_for("Mars") in out
|
||||
assert G.RETROGRADE in out
|
||||
assert "[Xyz" in out # nieznany token bez zmian
|
||||
|
||||
|
||||
def test_glyphify_empty_is_safe():
|
||||
assert G.glyphify("") == ""
|
||||
@@ -32,6 +32,18 @@ h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
|
||||
button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
|
||||
td.retro { color: #ff9b6a; font-weight: 600; }
|
||||
/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
|
||||
(DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
|
||||
1) selektor wariantu U+FE0E w samych danych (glyphs.py),
|
||||
2) font-variant-emoji:text — nowsza własność CSS,
|
||||
3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
|
||||
fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
|
||||
znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
|
||||
„Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
|
||||
.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
|
||||
font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
|
||||
"DejaVu Sans", sans-serif; }
|
||||
td.glyph { color: var(--accent); white-space: nowrap; }
|
||||
form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
|
||||
.row { display: flex; gap: .5rem; }
|
||||
.row input[type=text] { flex: 1; }
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
|
||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
||||
</tr>
|
||||
@@ -93,6 +94,7 @@
|
||||
<tbody>
|
||||
{% for p in result.positions %}
|
||||
<tr>
|
||||
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.sign }}</td>
|
||||
<td class="mono">{{ p.in_sign }}</td>
|
||||
@@ -140,7 +142,7 @@
|
||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in result.aspects %}
|
||||
<tr><td>{{ a.obj1 }}</td><td>{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
Reference in New Issue
Block a user