Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2892b71be3 | |||
| 6f87b2b323 | |||
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 |
@@ -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,7 +1,7 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [master]
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -16,4 +16,4 @@ jobs:
|
||||
docker build -t gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG ./services/$SVC
|
||||
docker push gitea.czernobog.pl/gitea/astrololo-$SVC:$TAG
|
||||
done
|
||||
echo "Tag: $TAG"
|
||||
echo "Tag: $TAG"
|
||||
|
||||
@@ -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
|
||||
@@ -73,3 +73,4 @@ export DATA_PROVIDER=sql # przełącz całą warstwę
|
||||
`SqlDataProvider` realizuje ten sam kontrakt `/search`, więc **warstwa logiczna i
|
||||
prezentacji nie zmieniają ani jednej linii**. Odwrócony indeks z L4 (SQLite) jest
|
||||
już pomostem — rozbudowa o wszystkie kolumny = docelowa baza.
|
||||
# updater test Mon 20 Jul 2026 19:52:08 CEST
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /wheels /wheels
|
||||
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 . .
|
||||
|
||||
# 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
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8003"]
|
||||
|
||||
@@ -15,9 +15,23 @@ permisywnej reszty systemu. **Nie wchodzi do dystrybucji zamkniętego produktu.*
|
||||
|
||||
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 ~1–2 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)
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt # wymaga kompilatora C (patrz wyżej)
|
||||
uvicorn app.main:app --port 8003
|
||||
```
|
||||
Następnie w warstwie logicznej ustaw `ENGINE_SWISSEPH_URL=http://localhost:8003`,
|
||||
|
||||
@@ -6,7 +6,7 @@ i nie w bazie.
|
||||
|
||||
## API
|
||||
- `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/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)
|
||||
|
||||
@@ -24,6 +24,8 @@ ABBREVIATIONS: dict[str, str] = {
|
||||
"NN": "North Node", "SN": "South Node", "Lilith": "Lilith", "Chiron": "Chiron",
|
||||
# osie
|
||||
"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
|
||||
"conj": "conjunction", "sex": "sextile", "sq": "square", "tri": "trine",
|
||||
"opp": "opposition", "semisex": "semisextile", "semisq": "semisquare",
|
||||
|
||||
@@ -7,27 +7,51 @@ pozycje.
|
||||
from __future__ import annotations
|
||||
|
||||
from app.engine import houses as H
|
||||
from app.engine import zodiac as Z
|
||||
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
|
||||
|
||||
|
||||
def _fmt(name: str, lon: float) -> dict:
|
||||
def _fmt(name: str, lon: float, off: float = 0.0) -> dict:
|
||||
lon = norm360(lon - off)
|
||||
return {
|
||||
"name": name,
|
||||
"sign": SIGNS[sign_index(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
|
||||
|
||||
positions = engine.positions(moment)
|
||||
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)
|
||||
|
||||
# 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"):
|
||||
return result
|
||||
|
||||
@@ -35,19 +59,37 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
asc = H.compute_asc(ramc, eps, moment.lat)
|
||||
mc = H.compute_mc(ramc, eps)
|
||||
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["angles"] = {
|
||||
"Asc": _fmt("Asc", asc),
|
||||
"MC": _fmt("MC", mc),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0)),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0)),
|
||||
"Asc": _fmt("Asc", asc, off),
|
||||
"MC": _fmt("MC", mc, off),
|
||||
"Dsc": _fmt("Dsc", norm360(asc + 180.0), off),
|
||||
"IC": _fmt("IC", norm360(mc + 180.0), off),
|
||||
}
|
||||
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 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
|
||||
|
||||
@@ -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
|
||||
@@ -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 1900–2100 (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 0–360°; 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)
|
||||
@@ -39,6 +39,7 @@ class PositionsRequest(BaseModel):
|
||||
objects: list[str] | None = None
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||
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)
|
||||
@@ -58,7 +59,10 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
|
||||
engine = get_engine()
|
||||
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:
|
||||
from app.engine.stations import find_stations
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ PLANET_ABBR = {
|
||||
"Jupiter": "Ju", "Saturn": "Sa", "Uranus": "Ur", "Neptune": "Ne", "Pluto": "Pl",
|
||||
# punkty wirtualne — tokeny wg SIGNIFICATORS KEY ([NN, [SN, [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))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ służą jako wyrocznia (LOG-25).
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -28,4 +29,8 @@ def own_engine():
|
||||
try:
|
||||
return SkyfieldEngine()
|
||||
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}")
|
||||
|
||||
@@ -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")
|
||||
@@ -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 1900–2100.
|
||||
"""
|
||||
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,
|
||||
house_system: str = "whole_sign",
|
||||
stations: bool = False,
|
||||
zodiac: str = "tropical",
|
||||
) -> dict[str, Any]:
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
@@ -40,6 +41,7 @@ class LogicClient:
|
||||
"objects": objects,
|
||||
"house_system": house_system,
|
||||
"stations": stations,
|
||||
"zodiac": zodiac,
|
||||
}
|
||||
# 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:
|
||||
|
||||
@@ -15,3 +15,17 @@ class 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}
|
||||
|
||||
@@ -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
|
||||
@@ -12,12 +12,14 @@ from __future__ import annotations
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app import geocode
|
||||
from app.clients.logic_client import LogicClient
|
||||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||||
|
||||
app = FastAPI(title="astrololo · warstwa prezentacji")
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
@@ -48,7 +50,10 @@ def _logic_error(e: Exception) -> str:
|
||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
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)
|
||||
@@ -61,16 +66,18 @@ def chart_compute(
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
stations: bool = Form(False),
|
||||
zodiac: str = Form("tropical"),
|
||||
):
|
||||
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}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
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:
|
||||
ctx["error"] = _logic_error(e)
|
||||
@@ -105,7 +112,10 @@ def significators_search(
|
||||
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||||
@app.get("/interpret", response_class=HTMLResponse)
|
||||
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)
|
||||
@@ -135,7 +145,10 @@ def interpret_run(
|
||||
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||||
@app.get("/timeline", response_class=HTMLResponse)
|
||||
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)
|
||||
@@ -166,6 +179,25 @@ def timeline_run(
|
||||
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")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -32,6 +32,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
function (pos) {
|
||||
document.querySelector('input[name=lat]').value = pos.coords.latitude.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 ✓');
|
||||
},
|
||||
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;
|
||||
background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
|
||||
.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>
|
||||
</select>
|
||||
</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>
|
||||
{% include "_location_picker.html" %}
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
</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">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
<button type="submit">Policz horoskop</button>
|
||||
@@ -53,6 +65,7 @@
|
||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||
obiektów: {{ result.positions | length }}
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
@@ -88,6 +101,18 @@
|
||||
</tbody>
|
||||
</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 %}
|
||||
{% if with_stations %}
|
||||
<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 }}">
|
||||
</label>
|
||||
</div>
|
||||
{% include "_location_picker.html" %}
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="group" value="true" {{ 'checked' if form.group else '' }}> grupuj identyczne opisy</label>
|
||||
</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">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</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 }}">
|
||||
</label>
|
||||
</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">
|
||||
<label>Zakres od
|
||||
<input type="date" name="from_date" value="{{ form.from_date or '' }}" required>
|
||||
|
||||
Reference in New Issue
Block a user