Compare commits

..

17 Commits

Author SHA1 Message Date
gitea 2892b71be3 docs: wymagania feature'u horoskop AI (LOG-29..32, PRE-14/15, Q-12/13)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m42s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 29s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 18s
build / build (push) Successful in 49s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m44s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 26s
Nowy feature: generowanie promptow do ChatGPT/Claude z naszych wyliczen i
wolanie modelu — horoskop urodzeniowy (ekran Interpretacje) oraz horoskop
na wybrany okres (ekran Kalendarz).

Warstwa logiczna:
- LOG-29 generator promptow (profile natal + okresowy), prompt i wynik PL,
  obowiazek odnoszenia kazdej tezy do konkretnego sygnifikatora
- LOG-30 ograniczanie rozmiaru: dedup -> grupowanie -> sortowanie wg wagi
  (LOG-21) -> obciecie ogona -> skracanie; raportuje ile pominieto
- LOG-31 pluggable LLMProvider (OpenAI/Anthropic) + POST /chart/horoscope;
  klucz tylko z sekretu, limity kosztu/tokenow, prompt zwracany zawsze
- LOG-32 (Must) poufnosc: prompt niesie WLASNOSC wspolpracownika i dane
  urodzeniowe -> zgoda wlasciciela baz, API zamiast czatu konsumenckiego,
  minimalizacja, podglad przed wyslaniem, tryb bez pelnych opisow

Warstwa prezentacji:
- PRE-14 przycisk generowania + budzet promptu + podglad promptu przed
  wyslaniem + kopiowanie (prompt dziala takze bez wysylki do API)
- PRE-15 transparentnosc: dostawca/model, ile wskazan pominieto,
  zastrzezenie ze to nie porada medyczna, ostrzezenie przed wysylka

Pytania otwarte: Q-12 (zgoda wlasciciela baz — blokuje tryb pelnych opisow),
Q-13 (dostawca/model i limit kosztow).

Zaktualizowane liczniki w arkuszu Przeglad i zakresy autofiltrow.
Decyzje wg ustalen w czacie: integracja API, pelne opisy z baz, PL, suwak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 16:46:44 +02:00
gitea 6f87b2b323 feat(logic): systemy zodiaku — syderyczny, draconic (LOG-04)
build / build (push) Successful in 1m6s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m51s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 25s
Testy / Kontrola składni wszystkich warstw (push) Successful in 20s
Jedyne wymaganie Must bez implementacji. Nowy zodiac.py + wpiecie w horoskop:

- zodiac.py: ayanamsy (Lahiri, Fagan-Bradley, Krishnamurti) modelem
  ayan(jd)=ayan0+B*x+C*x^2 (wspolna precesja, rozna stala) — skalibrowanym
  do Swiss Ephemeris jako WYROCZNI: zgodnosc do ~0,02" w latach 1900-2100.
  Draconic = wzgledem wzla wznoszacego (wzel = 0 Barana). RA: konwersja
  ekliptyka->rownik (to_equatorial) na przyszly widok rownikowy.
- chart.py: build_chart(..., zodiac): offset jednolicie przesuwa etykiety
  znakow/dlugosci obiektow, osi, cusps i Lots; DOMY licza sie po dlugosci
  tropikalnej (geometria niezmiennicza wzgledem obrotu -> numery domow bez
  zmian). Domyslnie tropical -> sciezka i wyniki bez zmian.
- main.py: /chart/positions przyjmuje `zodiac`; bledny -> 422.
- prezentacja: dropdown „Zodiak" + pokazanie ayanamshy w wynikach.

Testy (14): ayanamsy vs wyrocznia swisseph (<0.1"), julian_day, draconic
(wzel=0 Barana), niezmienniczosc domow, RA w punktach charakterystycznych,
odrzucenie bledow. Cala logika: 99 passed, 1 skipped. Zweryfikowane e2e w
przegladarce (horoskop syderyczny Lahiri: Slonce Aries 16°34', ayan 23.6382).

RA jako osobny widok zodiaku (per-obiekt, z szerokoscia) — do osobnego PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 14:32:51 +00:00
gitea a2aabd37a5 feat(presentation): wyszukiwarka lokalizacji + mapa (OSM/Leaflet)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m39s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 28s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 21s
build / build (push) Successful in 57s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m47s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 29s
Testy / Kontrola składni wszystkich warstw (push) Successful in 22s
QoL 2/2: pole „Szukaj miejsca" (nazwa/adres/POI) + interaktywna mapa na
formularzach horoskopu / interpretacji / kalendarza. Bez klucza API.

- geocode.py: proxy OSM/Nominatim PO STRONIE SERWERA (poprawny User-Agent,
  throttling ~1 req/s, cache TTL 1h) — endpointy /geocode i /reverse.
  Wolanie z serwera, nie z przegladarki: latwiej trzymac polityke Nominatim
  i dziala niezaleznie od secure-context (http://<ip>).
- _location_picker.html: wspolny partial (search + wyniki + mapa + atrybucja),
  wpiety includem do 3 formularzy z polami lat/lon.
- geo.js: Leaflet — wyszukiwanie (debounce), klik na wynik ustawia lat/lon i
  centruje mape, klik/drag pineski ustawia wspolrzedne + /reverse pokazuje
  nazwe; sync z „Tu i teraz" (now.js emituje event astrololo:coords).
- Leaflet 1.9.4 vendorowany lokalnie (static/vendor/leaflet, BSD-2-Clause,
  permisywny) — niezaleznosc od CDN; kafelki mapy z OSM. Marker jako divIcon
  (bez plikow PNG).
- styles.css: style pod ciemny motyw.

Zweryfikowane w przegladarce: domyslny widok (pineska na Szpitalu Barlickiego),
wyszukanie „Wawel Krakow" -> lista -> klik ustawia 50.0547/19.9361 i przesuwa
mape, klik w mape ustawia wspolrzedne + reverse wypelnia nazwe. Zero bledow
w konsoli. /geocode i /reverse zwracaja szpital; cache dziala.

Uwaga wdrozeniowa: pod prezentacji potrzebuje egressu do
nominatim.openstreetmap.org; przegladarki — do tile.openstreetmap.org.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 03:32:07 +02:00
gitea de5d58958c feat(presentation): domyslna lokalizacja = Szpital Barlickiego, Lodz
build / build (push) Successful in 1m9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m43s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 47s
Testy / Kontrola składni wszystkich warstw (push) Successful in 24s
QoL: formularze (horoskop / interpretacje / kalendarz) maja wstepnie
wpisana lokalizacje urodzenia wlasciciela — Szpital Barlickiego w Lodzi
(51.7739N, 19.4829E; potwierdzone reverse-geokodowaniem OSM: Kopcinskiego
22/28). Nie trzeba jej wpisywac za kazdym razem.

- config.py: jedno zrodlo prawdy (DEFAULT_LAT/LON/LABEL, nadpisywalne ENV)
  + helper default_form().
- main.py: GET wstrzykuje default_form() + location_label do 3 formularzy
  z polami lokalizacji (significators pominiete — nie ma tam lat/lon).
- szablony: dyskretna podpowiedz z nazwa lokalizacji, widoczna tylko na
  czystym formularzu (po POST znika, wygrywa wpisana wartosc).
- „Tu i teraz" nadal nadpisuje domyslne wspolrzedne geolokalizacja.

Zweryfikowane TestClientem: 3 strony renderuja 51.7739/19.4829 + etykiete;
POST z innymi wspolrzednymi je zachowuje i chowa podpowiedz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 01:16:24 +00:00
gitea ab6fc0722c ci(swisseph): osobny workflow budujacy obraz silnika B
build-swisseph / build (push) Successful in 4m13s
build / build (push) Successful in 1m45s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m52s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 1m13s
Testy / Kontrola składni wszystkich warstw (push) Successful in 39s
Buduje i pushuje obraz gitea/astrololo-engine-swisseph (tag SHA + latest).
Celowo oddzielony od build.yaml (data/logic/presentation) — odpala sie
tylko przy zmianach w services/engine-swisseph/**, wiec obraz AGPL nie
miesza sie do pipeline'u permisywnego produktu. workflow_dispatch pozwala
zbootstrapowac pierwszy obraz recznie.

Obraz konsumuje profil deployu astrololo-swisseph (repo deploy).

Uwaga: pierwszy build zadziala dopiero po zmergowaniu poprawki Dockerfile
(PR #3) — na masterze Dockerfile jeszcze sie nie buduje.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:10:55 +00:00
gitea 6ced2ddd22 fix workflows
build / build (push) Successful in 1m1s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m39s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 6m53s
Testy / Kontrola składni wszystkich warstw (push) Successful in 27s
2026-07-20 18:50:54 +00:00
gitea 79bce8ae90 Lots / punkty arabskie — 7 Lots hermetycznych (LOG-08)
- engine/lots.py: formuła Lot = C + A − B z odwracaniem w horoskopach nocnych
  (zamiana A<->B); 7 Lots hermetycznych (Fortuna, Duch, Eros, Konieczność,
  Odwaga, Zwycięstwo, Nemezis) — Fortuna i Duch liczone pierwsze, bo pozostałe
  się do nich odwołują. Dwa warianty: degree (domyślny) i sign (całe znaki).
- build_chart: liczy sektę (reużyta is_day_birth z Firdarii) i zwraca lots
  ze znakiem, pozycją i domem; parametr lots_method.
- wyszukiwarka: token [PF (tak Fortuna występuje w realnej bazie) + rozwinięcie
  skrótu do "Part of Fortune".
- widok Horoskop: tabela Lots z formułami i sektą.

Walidacja (dwie niezależne wyrocznie z notes3):
- Fortuna = Can 12°35'27" vs astro-seek Can 12°35'24" (3 sekundy różnicy), dom 1;
- Duch potwierdzony przez swoją antyscję (Taurus 28°14' z tabeli antyscji);
- odwracanie nocne: Fortuna nocna == Duch dzienny i odwrotnie;
- Lots pochodne faktycznie używają Fortuny/Ducha; wariant sign trafia w 0° znaku.
85 testów przechodzi (nowy test_lots).

Odblokowuje Zodiacal Releasing (LOG-11), które startuje z Fortuny/Ducha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:50:54 +00:00
gitea 85e9182f12 fix(swisseph): naprawa builda obrazu silnika B
build / build (push) Successful in 43s
pyswisseph to rozszerzenie C, a na PyPI (2.10.3.2) gotowe wheels koncza sie
na cp311 i obejmuja wylacznie i686/x86_64. Obraz stoi na python:3.12-slim,
wiec pip ZAWSZE kompilowal ze zrodel - a slim nie ma kompilatora. Stad fail.

- Dockerfile wieloetapowy: kompilacja w etapie builder (build-essential),
  do runtime trafia juz tylko gotowy wheel - obraz zostaje czysty i maly.
  Dziala tez na arm64, gdzie wheeli linuksowych nie ma dla zadnej wersji.
- sanity check (import swisseph) na etapie builda, zeby niedzialajacy silnik
  wywracal build, a nie dopiero pierwszy request.
- CI: nowy job swisseph-image - realny docker build + smoke test /health
  i /positions na horoskopie referencyjnym.
- README: udokumentowany powod wieloetapowego builda.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:30:10 +02:00
gitea c75f8377bf kick image updater
build / build (push) Successful in 49s
2026-07-20 19:52:21 +02:00
gitea 5234e86c95 ttt
build / build (push) Successful in 1m18s
2026-07-20 14:50:11 +02:00
gitea 0ac8df250c test image updatera
build / build (push) Successful in 51s
2026-07-19 23:20:39 +02:00
gitea 9323803cb4 tt
build / build (push) Successful in 5m31s
2026-07-18 21:57:33 +02:00
gitea 678c3bad76 test 2 2026-07-18 18:11:42 +02:00
gitea 88a1ecd3c4 test akcji 2026-07-18 18:04:16 +02:00
gitea b28c612bf7 CI: jawne pobranie jądra efemeryd + brak silnika = błąd zamiast pomijania
Pierwszy run CI dał 59 passed / 20 skipped zamiast 78/1: auto-pobranie jądra
przez Skyfield nie powiodło się na runnerze, więc testy referencyjne (walidacja
względem astro.com) cicho znikały.

- workflow: jawne pobranie de421.bsp curlem z ssd.jpl.nasa.gov (naif zwraca 404),
  z retry; EPHEMERIS_DIR wskazany explicite; pytest z -rs (widoczne powody skipów).
- conftest: gdy CI=true, niedostępny silnik kończy się pytest.fail zamiast skip —
  żeby utrata pokrycia nigdy więcej nie przeszła niezauważona. Lokalnie (dev bez
  pobranego jądra) nadal łagodny skip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:47:50 +02:00
gitea 2bda4e311d CI: uruchamianie testów przy każdym pushu (GitHub Actions)
.github/workflows/tests.yml:
- job "Testy warstwy logicznej": Python 3.12 (jak w obrazach Dockera),
  instalacja requirements-dev, pytest na services/logic/tests. Cache pip oraz
  cache jądra efemeryd JPL (de421.bsp ~17 MB), by nie pobierać go co run.
- job "Kontrola składni": compileall na wszystkich warstwach (bez instalowania
  zależności, w tym AGPL-owego silnika swisseph).
- Triggery: każdy push (dowolna gałąź) + pull requesty do master.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:02:37 +02:00
gitea 5d0d86cb90 Merge pull request #22 from migatu/feat/firdaria-log11
Firdaria (LOG-11) — perska technika time-lord
2026-07-17 22:50:39 +02:00
31 changed files with 1599 additions and 23 deletions
+32
View File
@@ -0,0 +1,32 @@
name: build-swisseph
# Osobny pipeline dla silnika B (Swiss Ephemeris, AGPL) — celowo ODDZIELONY od
# głównego build.yaml (data/logic/presentation). Buduje się tylko, gdy zmienia się
# sam silnik, i nie miesza obrazu AGPL do pipeline'u permisywnego produktu.
#
# Obraz konsumuje profil deployu `astrololo-swisseph` w repo `deploy`.
on:
push:
branches: [master]
paths:
- 'services/engine-swisseph/**'
- '.gitea/workflows/build-swisseph.yaml'
workflow_dispatch: {} # ręczne odpalenie (bootstrap pierwszego obrazu)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.czernobog.pl -u gitea --password-stdin
- name: Build & push engine-swisseph (AGPL, izolowany)
run: |
TAG=${GITHUB_SHA::8}
IMG=gitea.czernobog.pl/gitea/astrololo-engine-swisseph
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312) — build jest
# zarazem realnym testem Dockerfile'a.
docker build -t $IMG:$TAG -t $IMG:latest ./services/engine-swisseph
docker push $IMG:$TAG
docker push $IMG:latest
echo "Zbudowano i wypchnięto: $IMG:$TAG (+ latest)"
+1 -1
View File
@@ -1,7 +1,7 @@
name: build name: build
on: on:
push: push:
branches: [main] branches: [master]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+96
View File
@@ -0,0 +1,96 @@
name: Testy
# Odpala się przy każdym pushu (dowolna gałąź) oraz dla pull requestów do master.
on:
push:
pull_request:
branches: [master]
jobs:
logic-tests:
name: Testy warstwy logicznej (silnik)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Python 3.12 (jak w obrazach Dockera)
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: services/logic/requirements-dev.txt
# Jądro efemeryd JPL (de421.bsp, ~17 MB). Cache'ujemy je między runami.
- name: Cache jądra efemeryd
uses: actions/cache@v4
with:
path: services/logic/.ephemeris
key: ephemeris-de421
# Pobieramy jawnie (a nie licząc na auto-pobranie przez Skyfield), żeby
# brak jądra był twardym błędem, a nie cichym pomijaniem testów.
- name: Pobierz jądro efemeryd (gdy brak w cache)
run: |
mkdir -p services/logic/.ephemeris
if [ ! -s services/logic/.ephemeris/de421.bsp ]; then
curl -fSL --retry 3 --max-time 300 \
-o services/logic/.ephemeris/de421.bsp \
https://ssd.jpl.nasa.gov/ftp/eph/planets/bsp/de421.bsp
fi
ls -lh services/logic/.ephemeris/de421.bsp
- name: Instalacja zależności
run: pip install -r services/logic/requirements-dev.txt
# CI=true (ustawiane przez GitHub) sprawia, że brak silnika = błąd,
# a nie pominięcie — patrz tests/conftest.py.
- name: Testy (pytest)
working-directory: services/logic
env:
PYTHONPATH: .
EPHEMERIS_DIR: ${{ github.workspace }}/services/logic/.ephemeris
run: pytest tests -q -rs
swisseph-image:
name: Build obrazu silnika B (swisseph)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Obraz kompiluje pyswisseph ze źródeł (brak wheeli dla cp312), więc ten
# build jest realnym testem Dockerfile'a — nie tylko pobraniem paczek.
- name: docker build
run: docker build -t astrololo/engine-swisseph:ci services/engine-swisseph
- name: Smoke test (health + pozycje)
run: |
docker run -d --name swe -p 8003:8003 astrololo/engine-swisseph:ci
for i in $(seq 1 30); do
curl -fsS http://localhost:8003/health >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://localhost:8003/health
echo
# Horoskop referencyjny (30.04.1984) — ten sam, na którym opieramy testy
# silnika własnego; sprawdzamy, że silnik B faktycznie liczy.
curl -fsS -X POST http://localhost:8003/positions \
-H 'Content-Type: application/json' \
-d '{"when_utc":"1984-04-30T09:20:00Z","lat":50.0647,"lon":19.9450}'
echo
- name: Logi kontenera (gdy coś padło)
if: failure()
run: docker logs swe || true
compile-all:
name: Kontrola składni wszystkich warstw
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# Sam kompilator — bez instalowania zależności warstw (w tym AGPL-owego
# silnika swisseph, który nie wchodzi do produktu).
- name: py_compile
run: python -m compileall -q services
+1
View File
@@ -73,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i `SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza. już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
# updater test Mon 20 Jul 2026 19:52:08 CEST
Binary file not shown.
+25 -1
View File
@@ -1,10 +1,34 @@
# Build wieloetapowy — bo `pyswisseph` to rozszerzenie C bez gotowych wheeli.
#
# Na PyPI (2.10.3.2) wheels kończą się na cp311 i obejmują wyłącznie i686/x86_64.
# Dla Pythona 3.12 oraz dla arm64 pip ZAWSZE kompiluje ze źródeł, a `-slim` nie ma
# kompilatora — dlatego jednoetapowy build tu padał. Kompilujemy w etapie builder,
# a do obrazu finalnego wchodzi już tylko gotowy wheel (bez toolchaina).
FROM python:3.12-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY . . COPY . .
# Sanity check na etapie budowania: brak działającego swissepha ma wywalić build,
# a nie dopiero pierwszy request.
RUN python -c "import swisseph as swe; swe.set_ephe_path(None); print('swisseph OK', swe.version)"
EXPOSE 8003 EXPOSE 8003
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
+15 -1
View File
@@ -15,9 +15,23 @@ permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.*
Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji. Tryb Moshiera (`FLG_MOSEPH`) — bez plików efemeryd, zero konfiguracji.
## Build obrazu
```bash
docker compose --profile comparison build engine-swisseph
```
Dockerfile jest **wieloetapowy** i to nie jest ozdobnik: `pyswisseph` to rozszerzenie
C, a na PyPI (2.10.3.2) gotowe wheels kończą się na **cp311** i obejmują wyłącznie
**i686/x86_64**. Dla Pythona 3.12 oraz dla arm64 pip musi kompilować ze źródeł, więc
sam `python:3.12-slim` (bez kompilatora) build wywracał. Kompilacja idzie w etapie
`builder` (`build-essential`), a do obrazu finalnego trafia już tylko gotowy wheel —
runtime zostaje czysty i mały. Pierwszy build trwa ~12 min, kolejne idą z cache warstw.
Build kończy się sanity-checkiem (`import swisseph`), żeby niedziałający silnik
wykrzaczył build, a nie dopiero pierwszy request.
## Uruchomienie (tylko profil porównawczy / dev / CI) ## Uruchomienie (tylko profil porównawczy / dev / CI)
```bash ```bash
pip install -r requirements.txt pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
uvicorn app.main:app --port 8003 uvicorn app.main:app --port 8003
``` ```
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`, Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
+1 -1
View File
@@ -6,7 +6,7 @@ i nie w bazie.
## API ## API
- `POST /api/query``QueryRequest``QueryResponse` - `POST /api/query``QueryRequest``QueryResponse`
- `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`. - `POST /chart/positions``{when_utc, lat, lon, house_system?}` → pełny horoskop: pozycje (LOG-01) + osie i domy (LOG-05) + aspekty główne z applying/separating (LOG-06) + opcjonalnie stacje planet (`stations:true`, LOG-03). Zwraca też sektę i 7 Lots hermetycznych z domami (LOG-08). Obiekty: 10 planet + mean NN/SN/Lilith (LOG-02). `house_system`: `whole_sign` (dom.) / `equal` / `porphyry`.
- `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów) - `POST /chart/report``{when_utc, lat, lon, limit?}` → wynik obliczeń wyszukany w bazie: fasety sygnifikatorów **w znaku / w domu / w aspekcie**, z rozwinięciem skrótów, odsiewaniem duplikatów (ten sam sygnifikator i opis), rankingiem siły (LOG-21) oraz opcją group (grupowanie identycznych opisów)
- `POST /chart/profections``{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10) - `POST /chart/profections``{when_utc, lat, lon, start_age?, count?}` → profekcje roczne: wiek, profektowany Asc, Władca Roku (+MC/Su/Mo) (LOG-10)
- `POST /chart/return``{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12) - `POST /chart/return``{when_utc, lat, lon, kind, around?}` → Solar/Lunar Return: moment powrotu + pełny horoskop na ten moment (LOG-12)
+2
View File
@@ -24,6 +24,8 @@ ABBREVIATIONS: dict[str, str] = {
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron", "NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
# osie # osie
"Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli", "Asc": "Ascendant", "Dsc": "Descendant", "MC": "Midheaven", "IC": "Imum Coeli",
# Lots (punkty arabskie)
"PF": "Part of Fortune", "Fortune": "Part of Fortune", "Spirit": "Lot of Spirit",
# aspekty # aspekty
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine", "conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare", "opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
+53 -11
View File
@@ -7,27 +7,51 @@ pozycje.
from __future__ import annotations from __future__ import annotations
from app.engine import houses as H from app.engine import houses as H
from app.engine import zodiac as Z
from app.engine.base import EphemerisEngine from app.engine.base import EphemerisEngine
from app.engine.formats import SIGNS, in_sign, norm360, sign_index from app.engine.formats import SIGNS, absolute, decimal, in_sign, norm360, sign_index
from app.engine.models import ChartMoment from app.engine.models import ChartMoment
def _fmt(name: str, lon: float) -> dict: def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
lon = norm360(lon - off)
return { return {
"name": name, "name": name,
"sign": SIGNS[sign_index(lon)], "sign": SIGNS[sign_index(lon)],
"in_sign": in_sign(lon), "in_sign": in_sign(lon),
"decimal": round(norm360(lon), 6), "decimal": round(lon, 6),
} }
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict: def _shift_pos(pdict: dict, off: float) -> None:
"""Przelicza etykiety pozycji na wybrany zodiak (in-place). off=0 → bez zmian."""
if not off:
return
lon = norm360(pdict["decimal"] - off)
pdict["sign"] = SIGNS[sign_index(lon)]
pdict["in_sign"] = in_sign(lon)
pdict["absolute"] = absolute(lon)
pdict["decimal"] = decimal(lon)
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.aspects import find_aspects from app.engine.aspects import find_aspects
positions = engine.positions(moment) positions = engine.positions(moment)
result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]} result: dict = {"engine": engine.name, "positions": [p.as_dict() for p in positions]}
# aspekty liczymy PRZED zmianą zodiaku — kąty między obiektami są niezmiennicze
result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06) result["aspects"] = find_aspects(result["positions"]) # aspekty (LOG-06)
# 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)
result["zodiac"] = zodiac
if zodiac in Z.SIDEREAL:
result["ayanamsha"] = round(off, 6)
for pdict in result["positions"]:
_shift_pos(pdict, off)
if not hasattr(engine, "sidereal"): if not hasattr(engine, "sidereal"):
return result return result
@@ -35,19 +59,37 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
asc = H.compute_asc(ramc, eps, moment.lat) asc = H.compute_asc(ramc, eps, moment.lat)
mc = H.compute_mc(ramc, eps) mc = H.compute_mc(ramc, eps)
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
cusp_list = H.cusps(asc, mc, system) cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
result["house_system"] = system result["house_system"] = system
result["angles"] = { result["angles"] = {
"Asc": _fmt("Asc", asc), "Asc": _fmt("Asc", asc, off),
"MC": _fmt("MC", mc), "MC": _fmt("MC", mc, off),
"Dsc": _fmt("Dsc", norm360(asc + 180.0)), "Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
"IC": _fmt("IC", norm360(mc + 180.0)), "IC": _fmt("IC", norm360(mc + 180.0), off),
} }
result["cusps"] = [ result["cusps"] = [
{"house": i + 1, "sign": SIGNS[sign_index(c)], "in_sign": in_sign(c)} {"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
"in_sign": in_sign(norm360(c - off))}
for i, c in enumerate(cusp_list) for i, c in enumerate(cusp_list)
] ]
for pdict, obj in zip(result["positions"], positions): for pdict, obj in zip(result["positions"], positions):
pdict["house"] = H.assign_house(obj.longitude, cusp_list) pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
from app.engine.firdaria import is_day_birth
from app.engine.lots import compute_lots
pts = {p.name: p.longitude for p in positions}
pts["Asc"] = asc
day = is_day_birth(pts["Sun"], asc, mc) if "Sun" in pts else True
result["sect"] = "day" if day else "night"
result["lots"] = [
{**lot,
"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
for lot in compute_lots(pts, day, lots_method)
]
return result return result
+60
View File
@@ -0,0 +1,60 @@
"""Lots / punkty arabskie (LOG-08) — 7 Lots hermetycznych.
Formuła: Lot = C + A B (od punktu C odmierzamy odległość między A i B).
Większość Lots **odwraca się w horoskopach nocnych** (zamiana A↔B) — np.
Fortuna: dzień Asc + Mo Su, noc Asc + Su Mo.
Dwa warianty liczenia (notes3):
- `degree` (domyślny) — dokładny stopień,
- `sign` — liczone całymi znakami (Lot wypada na 0° wyliczonego znaku).
Kolejność ma znaczenie: Fortuna i Duch liczone są pierwsze, bo pozostałe Lots
odwołują się do nich.
"""
from __future__ import annotations
from app.engine.formats import norm360, sign_index
# (nazwa, C, A, B, odwracalny w nocy)
LOT_DEFS: list[tuple[str, str, str, str, bool]] = [
("Fortune", "Asc", "Moon", "Sun", True),
("Spirit", "Asc", "Sun", "Moon", True),
("Eros", "Asc", "Venus", "Spirit", True),
("Necessity", "Asc", "Fortune", "Mercury", True),
("Courage", "Asc", "Fortune", "Mars", True),
("Victory", "Asc", "Jupiter", "Spirit", True),
("Nemesis", "Asc", "Fortune", "Saturn", True),
]
METHODS = ("degree", "sign")
def compute_lots(
points: dict[str, float], is_day: bool, method: str = "degree"
) -> list[dict]:
"""points: nazwa → długość natalna (wymagane Asc + planety formuł).
Zwraca listę {name, longitude, formula} w kolejności definicji.
"""
if method not in METHODS:
raise ValueError(f"nieznana metoda liczenia Lots: {method}")
vals = dict(points)
out: list[dict] = []
for name, c, a, b, reversible in LOT_DEFS:
first, second = (a, b) if (is_day or not reversible) else (b, a)
if any(k not in vals for k in (c, first, second)):
continue # brak składnika — pomijamy
if method == "sign":
idx = (sign_index(vals[c]) + sign_index(vals[first])
- sign_index(vals[second])) % 12
lon = idx * 30.0
else:
lon = norm360(vals[c] + vals[first] - vals[second])
vals[name] = lon # dostępny dla kolejnych Lots
out.append({
"name": name,
"longitude": lon,
"formula": f"{c} + {first} {second}",
})
return out
+103
View File
@@ -0,0 +1,103 @@
"""Systemy zodiaku (LOG-04): tropikalny, syderyczny (ayanamsy), draconic + RA.
Wszystkie pozycje silnika są liczone **tropikalnie of-date** (kontrakt LOG-28).
Zmiana zodiaku to — dla zodiaków ekliptycznych — jednolite przesunięcie długości:
długość_docelowa = (długość_tropikalna offset) mod 360
gdzie offset to:
- **syderyczny**: ayanamsa (kąt między tropikalnym a syderycznym punktem Barana),
- **draconic**: długość wznoszącego węzła Księżyca (węzeł = 0° draconic),
- **tropikalny**: 0.
Ponieważ to stałe przesunięcie obiektu ORAZ cusps, **numery domów się nie zmieniają**
(geometria jest niezmiennicza względem obrotu) — przesuwamy tylko etykiety znaków.
Model ayanamsy: `ayan(jd) = ayan0 + B·x + C·x²`, gdzie `x = jd J2000`. Prędkość
precesji (B, C) jest **wspólna** dla wszystkich ayanams; różni je tylko stała `ayan0`
(wybór syderycznego zera). Stałe skalibrowano do Swiss Ephemeris jako wyroczni —
zgodność do ~0,02" w latach 19002100 (patrz tests/test_zodiac.py).
RA (right ascension): konwersja ekliptyka→równik dla przyszłego widoku równikowego.
"""
from __future__ import annotations
import math
from datetime import datetime
from app.engine.formats import norm360
TROPICAL = "tropical"
DRACONIC = "draconic"
# stała ayanamsy w J2000.0 (°) — skalibrowana do swisseph (get_ayanamsa_ut)
_AYAN0 = {
"lahiri": 23.857092,
"fagan_bradley": 24.740300,
"krishnamurti": 23.760240,
}
_J2000 = 2451545.0
_B = 3.824459e-5 # °/dobę — liniowy człon precesji (wspólny)
_C = 2.304e-13 # °/dobę² — drobne przyspieszenie (wspólne)
# nazwy zodiaków akceptowane przez API
SIDEREAL = tuple(f"sidereal_{k}" for k in _AYAN0) # sidereal_lahiri, ...
SYSTEMS = (TROPICAL, *SIDEREAL, DRACONIC)
def julian_day(dt: datetime) -> float:
"""Julian Day (UT) z momentu UTC — algorytm Meeusa (kalendarz gregoriański)."""
y, m = dt.year, dt.month
day = dt.day + (dt.hour + dt.minute / 60.0 + dt.second / 3600.0
+ dt.microsecond / 3.6e9) / 24.0
if m <= 2:
y -= 1
m += 12
a = y // 100
b = 2 - a + a // 4
return math.floor(365.25 * (y + 4716)) + math.floor(30.6001 * (m + 1)) + day + b - 1524.5
def ayanamsha(name: str, jd: float) -> float:
"""Ayanamsa [°] danej szkoły dla Julian Day (UT)."""
key = name[len("sidereal_"):] if name.startswith("sidereal_") else name
if key not in _AYAN0:
raise ValueError(f"Nieznana ayanamsa: {name!r} (dostępne: {', '.join(_AYAN0)})")
x = jd - _J2000
return _AYAN0[key] + _B * x + _C * x * x
def offset(zodiac: str, jd: float, node_lon: float | None = None) -> float:
"""Ile odjąć od długości tropikalnej, by dostać wybrany zodiak.
`node_lon` (tropikalna długość węzła wznoszącego) wymagana tylko dla draconic.
"""
if zodiac == TROPICAL:
return 0.0
if zodiac == DRACONIC:
if node_lon is None:
raise ValueError("draconic wymaga długości węzła (node_lon)")
return norm360(node_lon)
if zodiac in SIDEREAL:
return ayanamsha(zodiac, jd)
raise ValueError(f"Nieznany zodiak: {zodiac!r} (dostępne: {', '.join(SYSTEMS)})")
def apply(lon: float, off: float) -> float:
"""Długość w docelowym zodiaku."""
return norm360(lon - off)
def to_equatorial(lon: float, lat: float, eps: float) -> tuple[float, float]:
"""Ekliptyka (λ, β) → równik: (RA, deklinacja) w stopniach. Wszystko w °.
RA rośnie 0360°; deklinacja w [90, 90].
"""
lam, bet, e = math.radians(lon), math.radians(lat), math.radians(eps)
sin_dec = math.sin(bet) * math.cos(e) + math.cos(bet) * math.sin(e) * math.sin(lam)
dec = math.asin(max(-1.0, min(1.0, sin_dec)))
ra = math.atan2(
math.sin(lam) * math.cos(e) - math.tan(bet) * math.sin(e),
math.cos(lam),
)
return norm360(math.degrees(ra)), math.degrees(dec)
+5 -1
View File
@@ -39,6 +39,7 @@ class PositionsRequest(BaseModel):
objects: list[str] | None = None objects: list[str] | None = None
house_system: str = "whole_sign" # whole_sign | equal | porphyry house_system: str = "whole_sign" # whole_sign | equal | porphyry
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy) stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
@app.post("/api/query", response_model=QueryResponse) @app.post("/api/query", response_model=QueryResponse)
@@ -58,7 +59,10 @@ def chart_positions(req: PositionsRequest) -> dict:
engine = get_engine() engine = get_engine()
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon) moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
chart = build_chart(engine, moment, req.house_system) try:
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if req.stations: if req.stations:
from app.engine.stations import find_stations from app.engine.stations import find_stations
+2
View File
@@ -22,6 +22,8 @@ PLANET_ABBR = {
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl", "Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith) # punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [Lilith)
"North Node": "NN", "South Node": "SN", "Lilith": "Lilith", "North Node": "NN", "South Node": "SN", "Lilith": "Lilith",
# Lots — w bazie Fortuna występuje jako [PF (Part of Fortune)
"Fortune": "PF", "Spirit": "Spirit",
} }
SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR)) SIGN_TO_ABBR = dict(zip(SIGNS, SIGN_ABBR))
+5
View File
@@ -7,6 +7,7 @@ służą jako wyrocznia (LOG-25).
from __future__ import annotations from __future__ import annotations
import datetime as dt import datetime as dt
import os
import pytest import pytest
@@ -28,4 +29,8 @@ def own_engine():
try: try:
return SkyfieldEngine() return SkyfieldEngine()
except Exception as e: # brak efemeryd / brak sieci except Exception as e: # brak efemeryd / brak sieci
# Lokalnie pomijamy (dev bez pobranego jądra), ale w CI to musi być błąd —
# inaczej testy referencyjne (walidacja względem astro.com) cicho znikają.
if os.getenv("CI"):
pytest.fail(f"CI: silnik efemeryd niedostępny — testy referencyjne muszą działać: {e}")
pytest.skip(f"Nie można wczytać efemeryd: {e}") pytest.skip(f"Nie można wczytać efemeryd: {e}")
+71
View File
@@ -0,0 +1,71 @@
"""Lots / punkty arabskie (LOG-08).
Wyrocznie z notes3 (astro-seek, horoskop referencyjny 30.04.1984, urodzenie DZIENNE):
- Fortuna wprost w tabeli obiektów: Cancer 12°35'24" = 102.5900°
- Ducha (Spirit) potwierdza jego antyscja: Taurus 28°14' -> Spirit = 180 58.2333
"""
import pytest
from app.engine.formats import sign_index
from app.engine.lots import LOT_DEFS, compute_lots
# natalne długości horoskopu referencyjnego
NATAL = {"Asc": 112.1786, "Sun": 40.1392, "Moon": 30.5514, "Mercury": 27.3839,
"Venus": 27.6769, "Mars": 234.5411, "Jupiter": 282.9617, "Saturn": 223.3108}
FORTUNE_REF = 102.5900 # Cancer 12°35'24"
SPIRIT_REF = 180.0 - 58.2333 # z antyscji Taurus 28°14'
def _by_name(lots):
return {lot["name"]: lot["longitude"] for lot in lots}
def _arcmin(a, b):
return abs(((a - b + 180.0) % 360.0) - 180.0) * 60.0
def test_fortune_matches_astroseek():
lots = _by_name(compute_lots(NATAL, is_day=True))
assert _arcmin(lots["Fortune"], FORTUNE_REF) < 2.0
assert sign_index(lots["Fortune"]) == 3 # Cancer
def test_spirit_matches_antiscia_reference():
lots = _by_name(compute_lots(NATAL, is_day=True))
assert _arcmin(lots["Spirit"], SPIRIT_REF) < 2.0
def test_fortune_and_spirit_reverse_at_night():
day = _by_name(compute_lots(NATAL, is_day=True))
night = _by_name(compute_lots(NATAL, is_day=False))
# nocą formuła się odwraca: Fortuna nocna = Duch dzienny (i odwrotnie)
assert _arcmin(night["Fortune"], day["Spirit"]) < 0.01
assert _arcmin(night["Spirit"], day["Fortune"]) < 0.01
def test_all_seven_hermetic_lots_present():
lots = compute_lots(NATAL, is_day=True)
assert [lot["name"] for lot in lots] == [d[0] for d in LOT_DEFS]
assert len(lots) == 7
def test_derived_lots_use_fortune_and_spirit():
lots = _by_name(compute_lots(NATAL, is_day=True))
# Necessity = Asc + Fortune Mercury
expected = (NATAL["Asc"] + lots["Fortune"] - NATAL["Mercury"]) % 360.0
assert _arcmin(lots["Necessity"], expected) < 0.01
# Eros = Asc + Venus Spirit
expected_eros = (NATAL["Asc"] + NATAL["Venus"] - lots["Spirit"]) % 360.0
assert _arcmin(lots["Eros"], expected_eros) < 0.01
def test_by_sign_method_lands_on_sign_start():
lots = compute_lots(NATAL, is_day=True, method="sign")
for lot in lots:
assert lot["longitude"] % 30.0 == 0.0 # 0° wyliczonego znaku
def test_unknown_method_rejected():
with pytest.raises(ValueError):
compute_lots(NATAL, is_day=True, method="bzdura")
+127
View File
@@ -0,0 +1,127 @@
"""Systemy zodiaku (LOG-04): ayanamsy, syderyczny, draconic, RA.
Wartości referencyjne ayanams pochodzą ze **Swiss Ephemeris** (get_ayanamsa_ut)
użytego jako wyrocznia — silnika B nie importujemy tu (izolacja AGPL), więc stałe
są wpięte tak jak referencje astro-seek w innych testach. Nasz model odtwarza je
z dokładnością do ~0,02" w latach 19002100.
"""
import math
import pytest
from app.engine import zodiac as Z
from app.engine.chart import build_chart
from app.engine.formats import sign_index
# wyrocznia: swisseph get_ayanamsa_ut (JD UT -> ayanamsa °)
ORACLE = {
"sidereal_lahiri": {
2451545.0: 23.857092, 2445820.88889: 23.638183,
2415021.0: 22.460550, 2433283.0: 23.158744,
2469808.0: 24.555632, 2488070.0: 25.254287,
},
"sidereal_fagan_bradley": {
2451545.0: 24.740300, 2445820.88889: 24.521391,
2415021.0: 23.343757, 2433283.0: 24.041952,
2469808.0: 25.438840, 2488070.0: 26.137495,
},
"sidereal_krishnamurti": {
2451545.0: 23.760240, 2415021.0: 22.363697,
2469808.0: 24.458780, 2488070.0: 25.157435,
},
}
def _arcsec(a, b):
return abs(a - b) * 3600.0
@pytest.mark.parametrize("name", list(ORACLE))
def test_ayanamsha_matches_swisseph_oracle(name):
for jd, ref in ORACLE[name].items():
got = Z.ayanamsha(name, jd)
assert _arcsec(got, ref) < 0.1, f"{name} @ JD{jd}: {got} vs {ref} ({_arcsec(got, ref):.3f}\")"
def test_julian_day_j2000():
from datetime import datetime, timezone
jd = Z.julian_day(datetime(2000, 1, 1, 12, 0, tzinfo=timezone.utc))
assert abs(jd - 2451545.0) < 1e-6
def test_offset_tropical_is_zero():
assert Z.offset("tropical", 2451545.0) == 0.0
def test_offset_draconic_is_node_longitude():
assert Z.offset("draconic", 2451545.0, node_lon=68.0) == 68.0
# normalizacja
assert Z.offset("draconic", 2451545.0, node_lon=428.0) == pytest.approx(68.0)
def test_offset_draconic_requires_node():
with pytest.raises(ValueError):
Z.offset("draconic", 2451545.0)
def test_unknown_zodiac_rejected():
with pytest.raises(ValueError):
Z.offset("bzdura", 2451545.0)
with pytest.raises(ValueError):
Z.ayanamsha("sidereal_nieistnieje", 2451545.0)
def test_apply_subtracts_and_normalizes():
assert Z.apply(10.0, 20.0) == pytest.approx(350.0)
assert Z.apply(40.0, 24.74) == pytest.approx(15.26)
def test_to_equatorial_reference_points():
# punkt równonocy: λ=0,β=0 -> RA=0, dec=0
ra, dec = Z.to_equatorial(0.0, 0.0, 23.4392911)
assert ra == pytest.approx(0.0, abs=1e-9) and dec == pytest.approx(0.0, abs=1e-9)
# przesilenie letnie: λ=90,β=0 -> RA=90, dec=+ε
ra, dec = Z.to_equatorial(90.0, 0.0, 23.4392911)
assert ra == pytest.approx(90.0, abs=1e-6)
assert dec == pytest.approx(23.4392911, abs=1e-6)
# ---- integracja z pełnym horoskopem ----
def test_sidereal_chart_shifts_signs_back(own_engine, reference_moment):
trop = build_chart(own_engine, reference_moment, "whole_sign")
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
assert sid["zodiac"] == "sidereal_lahiri"
assert "ayanamsha" in sid and 23.0 < sid["ayanamsha"] < 24.5
tp = {p["name"]: p for p in trop["positions"]}
sp = {p["name"]: p for p in sid["positions"]}
# syderyczna długość = tropikalna ayanamsa (mod 360) dla każdego obiektu
ay = sid["ayanamsha"]
for name in tp:
expect = (tp[name]["decimal"] - ay) % 360.0
assert abs(((sp[name]["decimal"] - expect + 180) % 360) - 180) < 1e-4
def test_sidereal_preserves_house_numbers(own_engine, reference_moment):
# obrót zodiaku nie zmienia numerów domów (geometria niezmiennicza)
trop = build_chart(own_engine, reference_moment, "whole_sign")
sid = build_chart(own_engine, reference_moment, "whole_sign", zodiac="sidereal_lahiri")
tp = {p["name"]: p["house"] for p in trop["positions"]}
sp = {p["name"]: p["house"] for p in sid["positions"]}
assert tp == sp
def test_draconic_puts_node_at_zero_aries(own_engine, reference_moment):
drac = build_chart(own_engine, reference_moment, "whole_sign", zodiac="draconic")
assert drac["zodiac"] == "draconic"
nn = next(p for p in drac["positions"] if p["name"] == "North Node")
# węzeł wznoszący = 0° Barana w draconic
assert sign_index(nn["decimal"]) == 0
assert nn["decimal"] < 0.001 or nn["decimal"] > 359.999
def test_tropical_default_unchanged(own_engine, reference_moment):
# domyślnie tropikalny: brak ayanamshy, zodiac=tropical
chart = build_chart(own_engine, reference_moment, "whole_sign")
assert chart["zodiac"] == "tropical"
assert "ayanamsha" not in chart
@@ -31,6 +31,7 @@ class LogicClient:
objects: list[str] | None = None, objects: list[str] | None = None,
house_system: str = "whole_sign", house_system: str = "whole_sign",
stations: bool = False, stations: bool = False,
zodiac: str = "tropical",
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Pełny horoskop dla danego momentu — woła logic /chart/positions.""" """Pełny horoskop dla danego momentu — woła logic /chart/positions."""
payload = { payload = {
@@ -40,6 +41,7 @@ class LogicClient:
"objects": objects, "objects": objects,
"house_system": house_system, "house_system": house_system,
"stations": stations, "stations": stations,
"zodiac": zodiac,
} }
# stacje wymagają root-findów — dłuższy timeout # stacje wymagają root-findów — dłuższy timeout
with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client: with httpx.Client(timeout=max(settings.http_timeout, 60.0) if stations else settings.http_timeout) as client:
+14
View File
@@ -15,3 +15,17 @@ class Settings:
settings = Settings() settings = Settings()
# Domyślna (wklepana) lokalizacja formularzy — miejsce urodzenia właściciela:
# Szpital Barlickiego w Łodzi, 51°46'26.1"N 19°28'58.6"E (potwierdzone reverse-
# geokodowaniem OSM: Kopcińskiego 22/28). QoL — nie trzeba wpisywać jej za każdym
# razem. Przycisk „Tu i teraz" nadal ją nadpisuje geolokalizacją przeglądarki.
# Można nadpisać zmiennymi środowiskowymi (np. inny współpracownik).
DEFAULT_LAT: float = float(os.getenv("DEFAULT_LAT", "51.7739"))
DEFAULT_LON: float = float(os.getenv("DEFAULT_LON", "19.4829"))
DEFAULT_LOCATION_LABEL: str = os.getenv("DEFAULT_LOCATION_LABEL", "Szpital Barlickiego, Łódź")
def default_form() -> dict:
"""Wstępnie wypełnione pola lokalizacji na czystym formularzu (GET)."""
return {"lat": DEFAULT_LAT, "lon": DEFAULT_LON}
+95
View File
@@ -0,0 +1,95 @@
"""Proxy geokodera (OpenStreetMap / Nominatim) dla wyszukiwarki lokalizacji w UI.
QoL: pozwala wpisać nazwę / adres / POI i dostać współrzędne (oraz odwrotnie
z punktu na mapie nazwę miejsca). Wołamy Nominatim **po stronie serwera**, nie
z przeglądarki, bo:
- polityka Nominatim wymaga sensownego User-Agent i max ~1 zapytanie/s łatwiej
to wyegzekwować i cache'ować centralnie niż z wielu klientów,
- działa niezależnie od secure context" przeglądarki (na http://<ip> też).
Atrybucja © OpenStreetMap contributors" jest pokazywana w UI (mapa + wyniki).
Zależność sieciowa jest OPCJONALNA: brak internetu = wyszukiwarka zwraca błąd,
ale ręczne wpisanie lat/lon oraz mapa działają dalej.
"""
from __future__ import annotations
import threading
import time
from typing import Any
import httpx
NOMINATIM = "https://nominatim.openstreetmap.org"
# Nominatim wymaga identyfikującego User-Agent (inaczej blokuje). Kontakt = właściciel.
USER_AGENT = "astrololo/1.0 (self-hosted astrology tool; +mtuszowski@gmail.com)"
_MIN_INTERVAL = 1.1 # s — polityka Nominatim: max ~1 zapytanie/s
_CACHE_TTL = 3600.0 # s — powtórki tego samego zapytania z pamięci przez godzinę
_TIMEOUT = 6.0
_lock = threading.Lock() # serializuje wołania (throttling globalny w procesie)
_last_call = 0.0
_cache: dict[str, tuple[float, Any]] = {}
def _throttle() -> None:
global _last_call
wait = _MIN_INTERVAL - (time.monotonic() - _last_call)
if wait > 0:
time.sleep(wait)
_last_call = time.monotonic()
def _cached(key: str) -> Any | None:
hit = _cache.get(key)
if hit and (time.monotonic() - hit[0]) < _CACHE_TTL:
return hit[1]
return None
def _get(path: str, params: dict) -> Any:
"""Wołanie Nominatim z throttlingiem; zakłada trzymany `_lock`."""
_throttle()
r = httpx.get(
f"{NOMINATIM}{path}",
params=params,
headers={"User-Agent": USER_AGENT, "Accept-Language": "pl"},
timeout=_TIMEOUT,
)
r.raise_for_status()
return r.json()
def search(query: str, limit: int = 5) -> list[dict]:
"""Nazwa/adres/POI → lista kandydatów [{name, lat, lon}] (najlepsze pierwsze)."""
query = (query or "").strip()
if not query:
return []
key = f"s:{limit}:{query.lower()}"
with _lock:
cached = _cached(key)
if cached is not None:
return cached
data = _get("/search", {"q": query, "format": "jsonv2",
"limit": limit, "addressdetails": 0})
out = [
{"name": item.get("display_name", ""),
"lat": round(float(item["lat"]), 6),
"lon": round(float(item["lon"]), 6)}
for item in data if "lat" in item and "lon" in item
]
_cache[key] = (time.monotonic(), out)
return out
def reverse(lat: float, lon: float) -> dict:
"""Punkt (lat, lon) → nazwa miejsca {name, lat, lon}."""
key = f"r:{round(lat, 5)}:{round(lon, 5)}"
with _lock:
cached = _cached(key)
if cached is not None:
return cached
data = _get("/reverse", {"lat": lat, "lon": lon, "format": "jsonv2"})
out = {"name": data.get("display_name", ""),
"lat": round(lat, 6), "lon": round(lon, 6)}
_cache[key] = (time.monotonic(), out)
return out
+38 -6
View File
@@ -12,12 +12,14 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import httpx import httpx
from fastapi import FastAPI, Form, Request from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app import geocode
from app.clients.logic_client import LogicClient from app.clients.logic_client import LogicClient
from app.config import DEFAULT_LOCATION_LABEL, default_form
app = FastAPI(title="astrololo · warstwa prezentacji") app = FastAPI(title="astrololo · warstwa prezentacji")
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
@@ -48,7 +50,10 @@ def _logic_error(e: Exception) -> str:
# ---------------- Horoskop: pozycje (strona główna) ---------------- # ---------------- Horoskop: pozycje (strona główna) ----------------
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def chart_form(request: Request): def chart_form(request: Request):
return templates.TemplateResponse(request, "chart.html", {"result": None, "form": {}}) return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/", response_class=HTMLResponse) @app.post("/", response_class=HTMLResponse)
@@ -61,16 +66,18 @@ def chart_compute(
lon: float = Form(0.0), lon: float = Form(0.0),
house_system: str = Form("whole_sign"), house_system: str = Form("whole_sign"),
stations: bool = Form(False), stations: bool = Form(False),
zodiac: str = Form("tropical"),
): ):
form = {"date": date, "time": time, "tz_offset": tz_offset, form = {"date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations} "lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
"zodiac": zodiac}
ctx: dict = {"form": form, "result": None, "error": None, "moment": None} ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
try: try:
iso_utc, label = _build_utc(date, time, tz_offset) iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label ctx["moment"] = label
ctx["result"] = logic.positions( ctx["result"] = logic.positions(
when_utc_iso=iso_utc, lat=lat, lon=lon, when_utc_iso=iso_utc, lat=lat, lon=lon,
house_system=house_system, stations=stations, house_system=house_system, stations=stations, zodiac=zodiac,
) )
except (httpx.HTTPError,) as e: except (httpx.HTTPError,) as e:
ctx["error"] = _logic_error(e) ctx["error"] = _logic_error(e)
@@ -105,7 +112,10 @@ def significators_search(
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ---------------- # ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
@app.get("/interpret", response_class=HTMLResponse) @app.get("/interpret", response_class=HTMLResponse)
def interpret_form(request: Request): def interpret_form(request: Request):
return templates.TemplateResponse(request, "interpret.html", {"result": None, "form": {}}) return templates.TemplateResponse(
request, "interpret.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/interpret", response_class=HTMLResponse) @app.post("/interpret", response_class=HTMLResponse)
@@ -135,7 +145,10 @@ def interpret_run(
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ---------------- # ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
@app.get("/timeline", response_class=HTMLResponse) @app.get("/timeline", response_class=HTMLResponse)
def timeline_form(request: Request): def timeline_form(request: Request):
return templates.TemplateResponse(request, "timeline.html", {"result": None, "form": {}}) return templates.TemplateResponse(
request, "timeline.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
)
@app.post("/timeline", response_class=HTMLResponse) @app.post("/timeline", response_class=HTMLResponse)
@@ -166,6 +179,25 @@ def timeline_run(
return templates.TemplateResponse(request, "timeline.html", ctx) return templates.TemplateResponse(request, "timeline.html", ctx)
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
@app.get("/geocode")
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
try:
return {"results": geocode.search(q)}
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/reverse")
def geocode_reverse(lat: float, lon: float):
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
try:
return geocode.reverse(lat, lon)
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
@app.get("/health") @app.get("/health")
def health() -> dict: def health() -> dict:
return {"status": "ok", "layer": "presentation"} return {"status": "ok", "layer": "presentation"}
+116
View File
@@ -0,0 +1,116 @@
// Wyszukiwarka lokalizacji + mapa (OpenStreetMap / Leaflet, bez klucza API).
// Wpina się do formularza z polami input[name=lat] / input[name=lon]:
// - wpisanie nazwy/adresu/POI -> /geocode -> lista trafień -> klik ustawia lat/lon,
// - klik na mapie lub przeciągnięcie pineski -> ustawia lat/lon + /reverse pokazuje nazwę,
// - „Tu i teraz" (now.js) emituje event 'astrololo:coords' -> mapa się synchronizuje.
// Bez internetu wyszukiwarka/kafelki nie działają, ale ręczne lat/lon dalej tak.
(function () {
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
var picker = document.getElementById('geoPicker');
if (!picker || typeof L === 'undefined') return; // brak Leafleta = po cichu pas
var latEl = document.querySelector('input[name=lat]');
var lonEl = document.querySelector('input[name=lon]');
if (!latEl || !lonEl) return;
var searchEl = document.getElementById('geoSearch');
var searchBtn = document.getElementById('geoSearchBtn');
var mapToggle = document.getElementById('geoMapToggle');
var resultsEl = document.getElementById('geoResults');
var mapEl = document.getElementById('map');
var num = function (el) { var v = parseFloat(el.value); return isNaN(v) ? 0 : v; };
var curLat = function () { return num(latEl); };
var curLon = function () { return num(lonEl); };
var pin = L.divIcon({ className: 'geo-pin', html: '📍', iconSize: [24, 24], iconAnchor: [12, 24] });
var map = L.map(mapEl).setView([curLat(), curLon()], 12);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}).addTo(map);
var marker = L.marker([curLat(), curLon()], { draggable: true, icon: pin }).addTo(map);
// Kontener mógł być renderowany zanim ustaliła się jego szerokość — poprawka.
setTimeout(function () { map.invalidateSize(); }, 60);
function setCoords(lat, lon, fromMap) {
latEl.value = lat.toFixed(4);
lonEl.value = lon.toFixed(4);
marker.setLatLng([lat, lon]);
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
if (fromMap) reverseName(lat, lon);
}
function reverseName(lat, lon) {
fetch('/reverse?lat=' + lat + '&lon=' + lon)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.name) searchEl.value = d.name; })
.catch(function () {});
}
marker.on('dragend', function () {
var p = marker.getLatLng();
setCoords(p.lat, p.lng, true);
});
map.on('click', function (e) { setCoords(e.latlng.lat, e.latlng.lng, true); });
// ---- wyszukiwanie po nazwie ----
function renderResults(items) {
resultsEl.innerHTML = '';
if (!items.length) { resultsEl.hidden = true; return; }
items.forEach(function (it) {
var li = document.createElement('li');
li.textContent = it.name;
li.addEventListener('click', function () {
setCoords(it.lat, it.lon, false);
searchEl.value = it.name;
resultsEl.hidden = true;
});
resultsEl.appendChild(li);
});
resultsEl.hidden = false;
}
function doSearch() {
var q = searchEl.value.trim();
if (q.length < 3) { resultsEl.hidden = true; return; }
fetch('/geocode?q=' + encodeURIComponent(q))
.then(function (r) { return r.ok ? r.json() : { results: [] }; })
.then(function (d) { renderResults(d.results || []); })
.catch(function () { renderResults([]); });
}
var timer = null;
searchBtn.addEventListener('click', doSearch);
searchEl.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); doSearch(); } // nie wysyłaj formularza
});
searchEl.addEventListener('input', function () {
clearTimeout(timer); timer = setTimeout(doSearch, 500); // debounce (limit Nominatim)
});
// ---- zwijanie/rozwijanie mapy ----
mapToggle.addEventListener('click', function () {
var hidden = mapEl.hasAttribute('hidden');
if (hidden) {
mapEl.removeAttribute('hidden');
mapToggle.textContent = 'Ukryj mapę';
setTimeout(function () { map.invalidateSize(); map.setView([curLat(), curLon()]); }, 60);
} else {
mapEl.setAttribute('hidden', '');
mapToggle.textContent = 'Pokaż mapę';
}
});
// ---- synchronizacja z „Tu i teraz" ----
document.addEventListener('astrololo:coords', function () {
marker.setLatLng([curLat(), curLon()]);
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
});
});
})();
+1
View File
@@ -32,6 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
function (pos) { function (pos) {
document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4); document.querySelector('input[name=lat]').value = pos.coords.latitude.toFixed(4);
document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4); document.querySelector('input[name=lon]').value = pos.coords.longitude.toFixed(4);
document.dispatchEvent(new CustomEvent('astrololo:coords')); // zsynchronizuj mapę (geo.js)
say('Lokalizacja pobrana ✓'); say('Lokalizacja pobrana ✓');
}, },
function (err) { function (err) {
@@ -64,3 +64,20 @@ tr:last-child td { border-bottom: none; }
.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px; .badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
background: #2a2d4a; color: var(--accent); margin-left: .3rem; } background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; } .samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
.geo { margin-top: .9rem; }
.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
.geo-search input[type=search] { flex: 1; min-width: 12rem; }
.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
.geo-results li:last-child { border-bottom: none; }
.geo-results li:hover { background: var(--panel); color: #fff; }
.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
.geo-map[hidden] { display: none; }
.geo-attrib { margin: .4rem 0 0; }
.geo-attrib a { color: var(--accent); }
.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
.leaflet-control-attribution { font-size: .68rem; }
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
{# Wyszukiwarka lokalizacji + interaktywna mapa (OpenStreetMap / Leaflet, bez klucza API).
Wklejana do formularzy z polami input[name=lat] / input[name=lon]. Samowystarczalna:
ładuje CSS/JS Leafleta (vendorowane lokalnie) oraz geo.js. Kafelki mapy lecą z OSM. #}
<link rel="stylesheet" href="/static/vendor/leaflet/leaflet.css">
<div class="geo" id="geoPicker">
<div class="geo-search">
<input type="search" id="geoSearch" autocomplete="off"
placeholder="Szukaj miejsca (np. Szpital Barlickiego, Łódź)…">
<button type="button" id="geoSearchBtn" class="ghost">Szukaj</button>
<button type="button" id="geoMapToggle" class="ghost">Ukryj mapę</button>
</div>
<ul id="geoResults" class="geo-results" hidden></ul>
<div id="map" class="geo-map"></div>
<p class="muted small geo-attrib">
Wyszukiwarka i mapa: © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a>
contributors (Nominatim). Kliknij mapę lub przeciągnij pineskę, by ustawić współrzędne.
</p>
</div>
<script defer src="/static/vendor/leaflet/leaflet.js"></script>
<script defer src="/static/geo.js"></script>
@@ -32,11 +32,23 @@
<option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option> <option value="porphyry" {{ 'selected' if hs == 'porphyry' else '' }}>Porphyry</option>
</select> </select>
</label> </label>
<label>Zodiak
<select name="zodiac">
{% set zd = form.zodiac or 'tropical' %}
<option value="tropical" {{ 'selected' if zd == 'tropical' else '' }}>Tropikalny</option>
<option value="sidereal_lahiri" {{ 'selected' if zd == 'sidereal_lahiri' else '' }}>Syderyczny (Lahiri)</option>
<option value="sidereal_fagan_bradley" {{ 'selected' if zd == 'sidereal_fagan_bradley' else '' }}>Syderyczny (Fagan-Bradley)</option>
<option value="sidereal_krishnamurti" {{ 'selected' if zd == 'sidereal_krishnamurti' else '' }}>Syderyczny (Krishnamurti)</option>
<option value="draconic" {{ 'selected' if zd == 'draconic' else '' }}>Draconic</option>
</select>
</label>
</div> </div>
{% include "_location_picker.html" %}
<div class="opts"> <div class="opts">
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}> <label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
licz stacje planet (wolniejsze)</label> licz stacje planet (wolniejsze)</label>
</div> </div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions"> <div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button> <button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Policz horoskop</button> <button type="submit">Policz horoskop</button>
@@ -53,6 +65,7 @@
Silnik: <strong>{{ result.engine }}</strong> · Silnik: <strong>{{ result.engine }}</strong> ·
obiektów: {{ result.positions | length }} obiektów: {{ result.positions | length }}
{% if result.house_system %}· domy: {{ result.house_system }}{% endif %} {% if result.house_system %}· domy: {{ result.house_system }}{% endif %}
{% if result.zodiac %}· zodiak: {{ result.zodiac }}{% if result.ayanamsha is defined %} (ayanamsa {{ '%.4f'|format(result.ayanamsha) }}°){% endif %}{% endif %}
{% if moment %}· moment: {{ moment }}{% endif %} {% if moment %}· moment: {{ moment }}{% endif %}
</div> </div>
@@ -88,6 +101,18 @@
</tbody> </tbody>
</table> </table>
{% if result.lots %}
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
<table class="angles">
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
<tbody>
{% for l in result.lots %}
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %} {% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
{% if with_stations %} {% if with_stations %}
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div> <div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
@@ -23,9 +23,11 @@
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}"> <input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label> </label>
</div> </div>
{% include "_location_picker.html" %}
<div class="opts"> <div class="opts">
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label> <label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
</div> </div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
<div class="actions"> <div class="actions">
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button> <button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
<button type="submit">Szukaj interpretacji</button> <button type="submit">Szukaj interpretacji</button>
@@ -23,6 +23,8 @@
<input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}"> <input type="number" name="lon" step="0.0001" value="{{ form.lon if form.lon is not none else 0 }}">
</label> </label>
</div> </div>
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
{% include "_location_picker.html" %}
<div class="grid"> <div class="grid">
<label>Zakres od <label>Zakres od
<input type="date" name="from_date" value="{{ form.from_date or '' }}" required> <input type="date" name="from_date" value="{{ form.from_date or '' }}" required>
View File