Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2aabd37a5 | |||
| de5d58958c | |||
| ab6fc0722c | |||
| 6ced2ddd22 | |||
| 79bce8ae90 | |||
| 85e9182f12 | |||
| c75f8377bf | |||
| 5234e86c95 | |||
| 0ac8df250c | |||
| 9323803cb4 | |||
| 678c3bad76 | |||
| 88a1ecd3c4 | |||
| b28c612bf7 | |||
| 2bda4e311d | |||
| 5d0d86cb90 | |||
| 9d5b65ddbd | |||
| cff0ac194c | |||
| 73f39e7df8 | |||
| 96d983b26a | |||
| c4a181b810 | |||
| 97ad21d2e4 |
@@ -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)"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
name: build
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
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 (data, logic, presentation)
|
||||||
|
run: |
|
||||||
|
TAG=${GITHUB_SHA::8}
|
||||||
|
for SVC in data logic presentation; do
|
||||||
|
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"
|
||||||
@@ -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
|
`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
|
||||||
|
|||||||
@@ -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,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 ~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)
|
## 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`,
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ 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)
|
||||||
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc, posortowane (technique | significator | start | exact | end) (LOG-14)
|
- `POST /chart/firdaria` → `{when_utc, lat, lon}` → Firdaria: sekta (dzień/noc), okresy główne i podokresy time-lordów (LOG-11)
|
||||||
|
- `POST /chart/timeline` → `{when_utc, lat, lon, from_date, to_date, techniques?}` → zbiorcza oś czasu: profekcje + Solar Return + dyrekcje solar-arc + Firdaria, posortowane (technique | significator | start | exact | end); z interpret=true dopina interpretacje z bazy do dat (LOG-14, 1B->2B)
|
||||||
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
- `POST /chart/compare` → jak wyżej → raport różnic dwóch silników (LOG-26; wymaga silnika B)
|
||||||
- `GET /health` (sprawdza też warstwę bazodanową)
|
- `GET /health` (sprawdza też warstwę bazodanową)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ def _fmt(name: str, lon: float) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN) -> dict:
|
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||||
|
lots_method: str = "degree") -> dict:
|
||||||
from app.engine.aspects import find_aspects
|
from app.engine.aspects import find_aspects
|
||||||
|
|
||||||
positions = engine.positions(moment)
|
positions = engine.positions(moment)
|
||||||
@@ -50,4 +51,20 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
|||||||
]
|
]
|
||||||
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)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
"sign": SIGNS[sign_index(lot["longitude"])],
|
||||||
|
"in_sign": in_sign(lot["longitude"]),
|
||||||
|
"house": H.assign_house(lot["longitude"], cusp_list)}
|
||||||
|
for lot in compute_lots(pts, day, lots_method)
|
||||||
|
]
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Firdaria (LOG-11) — perska technika time-lord.
|
||||||
|
|
||||||
|
Sekwencja okresów głównych zależy od sekty (dzień/noc). Sekta: urodzenie dzienne,
|
||||||
|
gdy Słońce jest nad horyzontem, czyli po tej samej stronie osi Asc–Dsc co MC.
|
||||||
|
|
||||||
|
Klasyczne długości okresów (lata): Su 10, Ve 8, Me 13, Mo 9, Sa 11, Ju 12, Ma 7
|
||||||
|
(razem 70) + Węzeł Północny 3 + Węzeł Południowy 2 = 75 lat. Każdy okres główny
|
||||||
|
planety dzieli się na 7 podokresów (sub-lord w tej samej kolejności, cyklicznie).
|
||||||
|
Węzły — bez podokresów (najczęstsza konwencja).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DAY_ORDER = ["Sun", "Venus", "Mercury", "Moon", "Saturn", "Jupiter", "Mars"]
|
||||||
|
NIGHT_ORDER = ["Moon", "Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury"]
|
||||||
|
YEARS = {"Sun": 10, "Venus": 8, "Mercury": 13, "Moon": 9,
|
||||||
|
"Saturn": 11, "Jupiter": 12, "Mars": 7}
|
||||||
|
NODES = [("North Node", 3), ("South Node", 2)]
|
||||||
|
DAYS_PER_YEAR = 365.2422
|
||||||
|
|
||||||
|
|
||||||
|
def is_day_birth(sun_lon: float, asc: float, mc: float) -> bool:
|
||||||
|
"""Słońce nad horyzontem = ta sama półkula osi Asc–Dsc co MC."""
|
||||||
|
return (((sun_lon - asc) % 360.0) < 180.0) == (((mc - asc) % 360.0) < 180.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _date(birth: datetime, years: float) -> str:
|
||||||
|
return (birth + timedelta(days=years * DAYS_PER_YEAR)).date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria(birth: datetime, sun_lon: float, asc: float, mc: float) -> dict:
|
||||||
|
"""Pełny rozkład Firdarii: sekta, kolejność, okresy główne i podokresy."""
|
||||||
|
day = is_day_birth(sun_lon, asc, mc)
|
||||||
|
order = DAY_ORDER if day else NIGHT_ORDER
|
||||||
|
majors = [(lord, YEARS[lord]) for lord in order] + NODES
|
||||||
|
|
||||||
|
periods: list[dict] = []
|
||||||
|
age = 0.0
|
||||||
|
for lord, yrs in majors:
|
||||||
|
period = {"lord": lord, "years": yrs,
|
||||||
|
"start": _date(birth, age), "end": _date(birth, age + yrs)}
|
||||||
|
if lord in YEARS: # planeta -> 7 podokresów
|
||||||
|
sub_len = yrs / 7.0
|
||||||
|
i = order.index(lord)
|
||||||
|
sub_age = age
|
||||||
|
subs: list[dict] = []
|
||||||
|
for k in range(7):
|
||||||
|
sub_lord = order[(i + k) % 7]
|
||||||
|
subs.append({"lord": sub_lord,
|
||||||
|
"start": _date(birth, sub_age),
|
||||||
|
"end": _date(birth, sub_age + sub_len)})
|
||||||
|
sub_age += sub_len
|
||||||
|
period["sub"] = subs
|
||||||
|
periods.append(period)
|
||||||
|
age += yrs
|
||||||
|
return {"sect": "day" if day else "night", "order": order, "periods": periods}
|
||||||
@@ -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
|
||||||
@@ -58,13 +58,15 @@ def solar_arc_directions(
|
|||||||
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
if not (lo_age <= age <= hi_age) or (p == q and arc < 1e-6):
|
||||||
continue
|
continue
|
||||||
exact = _add_years(birth, age)
|
exact = _add_years(birth, age)
|
||||||
out.append(_row(
|
row = _row(
|
||||||
"solar_arc",
|
"solar_arc",
|
||||||
f"dyr. {p} {PL_NAME[asp]} {q}",
|
f"dyr. {p} {PL_NAME[asp]} {q}",
|
||||||
_add_years(birth, age - orb_years),
|
_add_years(birth, age - orb_years),
|
||||||
exact,
|
exact,
|
||||||
_add_years(birth, age + orb_years),
|
_add_years(birth, age + orb_years),
|
||||||
))
|
)
|
||||||
|
row.update(directed=p, aspect=asp, target=q) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -83,10 +85,13 @@ def profection_events(natal_asc: float, birth: datetime, lo: datetime, hi: datet
|
|||||||
if end < lo or start > hi:
|
if end < lo or start > hi:
|
||||||
continue
|
continue
|
||||||
sign = profected_sign(natal_asc, age)
|
sign = profected_sign(natal_asc, age)
|
||||||
out.append(_row(
|
lord = DOMICILE_RULERS[sign]
|
||||||
"profection", f"Władca Roku: {DOMICILE_RULERS[sign]} (Asc {sign}, wiek {age})",
|
row = _row(
|
||||||
|
"profection", f"Władca Roku: {lord} (Asc {sign}, wiek {age})",
|
||||||
start, start, end,
|
start, start, end,
|
||||||
))
|
)
|
||||||
|
row.update(lord=lord, sign=sign) # do budowy tokenów (1B->2B)
|
||||||
|
out.append(row)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +109,28 @@ def solar_return_events(engine, natal_moment, birth: datetime, lo: datetime, hi:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def firdaria_events(natal_points: dict[str, float], birth: datetime, lo: datetime, hi: datetime) -> list[dict]:
|
||||||
|
"""Starty okresów/podokresów Firdarii (LOG-11) nachodzące na okno."""
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
|
||||||
|
fd = firdaria(birth, natal_points["Sun"], natal_points["Asc"], natal_points["MC"])
|
||||||
|
out: list[dict] = []
|
||||||
|
for period in fd["periods"]:
|
||||||
|
if "sub" in period:
|
||||||
|
for s in period["sub"]:
|
||||||
|
if lo <= _as_dt(s["start"]) <= hi:
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']} / {s['lord']}",
|
||||||
|
s["start"], s["start"], s["end"])
|
||||||
|
row.update(fd_major=period["lord"], fd_sub=s["lord"])
|
||||||
|
out.append(row)
|
||||||
|
elif lo <= _as_dt(period["start"]) <= hi: # węzeł — bez podokresów
|
||||||
|
row = _row("firdaria", f"Firdaria: {period['lord']}",
|
||||||
|
period["start"], period["start"], period["end"])
|
||||||
|
row.update(fd_major=period["lord"])
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _as_dt(d) -> datetime:
|
def _as_dt(d) -> datetime:
|
||||||
if isinstance(d, datetime):
|
if isinstance(d, datetime):
|
||||||
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
return d if d.tzinfo else d.replace(tzinfo=timezone.utc)
|
||||||
@@ -119,7 +146,7 @@ def build_timeline(
|
|||||||
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
"""Scala wybrane techniki w jedną oś czasu, posortowaną po dacie dokładnej."""
|
||||||
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
lo, hi = _as_dt(from_d), _as_dt(to_d)
|
||||||
birth = natal_moment.when_utc
|
birth = natal_moment.when_utc
|
||||||
want = set(techniques or ["profection", "solar_return", "solar_arc"])
|
want = set(techniques or ["profection", "solar_return", "solar_arc", "firdaria"])
|
||||||
events: list[dict] = []
|
events: list[dict] = []
|
||||||
if "profection" in want:
|
if "profection" in want:
|
||||||
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
events += profection_events(natal_points["Asc"], birth, lo, hi)
|
||||||
@@ -127,5 +154,7 @@ def build_timeline(
|
|||||||
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
events += solar_return_events(engine, natal_moment, birth, lo, hi)
|
||||||
if "solar_arc" in want:
|
if "solar_arc" in want:
|
||||||
events += solar_arc_directions(natal_points, birth, lo, hi)
|
events += solar_arc_directions(natal_points, birth, lo, hi)
|
||||||
|
if "firdaria" in want:
|
||||||
|
events += firdaria_events(natal_points, birth, lo, hi)
|
||||||
events.sort(key=lambda e: e["exact"])
|
events.sort(key=lambda e: e["exact"])
|
||||||
return events
|
return events
|
||||||
|
|||||||
@@ -173,6 +173,27 @@ def chart_return(req: ReturnRequest) -> dict:
|
|||||||
"return_utc": hit.isoformat(), **chart}
|
"return_utc": hit.isoformat(), **chart}
|
||||||
|
|
||||||
|
|
||||||
|
class FirdariaRequest(BaseModel):
|
||||||
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
|
lat: float = 0.0
|
||||||
|
lon: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/chart/firdaria")
|
||||||
|
def chart_firdaria(req: FirdariaRequest) -> dict:
|
||||||
|
"""Firdaria (LOG-11): sekta + okresy główne i podokresy time-lordów."""
|
||||||
|
from app.engine import houses as H
|
||||||
|
from app.engine.firdaria import firdaria
|
||||||
|
from app.engine.models import ChartMoment
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
natal = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||||
|
ramc, eps = engine.sidereal(natal)
|
||||||
|
asc, mc = H.compute_asc(ramc, eps, natal.lat), H.compute_mc(ramc, eps)
|
||||||
|
sun = engine.positions(natal, ["Sun"])[0].longitude
|
||||||
|
return {"engine": engine.name, **firdaria(req.when_utc, sun, asc, mc)}
|
||||||
|
|
||||||
|
|
||||||
class TimelineRequest(BaseModel):
|
class TimelineRequest(BaseModel):
|
||||||
when_utc: datetime # moment urodzenia (UTC)
|
when_utc: datetime # moment urodzenia (UTC)
|
||||||
lat: float = 0.0
|
lat: float = 0.0
|
||||||
@@ -180,11 +201,15 @@ class TimelineRequest(BaseModel):
|
|||||||
from_date: str # zakres: YYYY-MM-DD
|
from_date: str # zakres: YYYY-MM-DD
|
||||||
to_date: str
|
to_date: str
|
||||||
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
techniques: list[str] | None = None # profection | solar_return | solar_arc
|
||||||
|
interpret: bool = False # dopnij interpretacje z bazy (1B->2B)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/chart/timeline")
|
@app.post("/chart/timeline")
|
||||||
def chart_timeline(req: TimelineRequest) -> dict:
|
def chart_timeline(req: TimelineRequest) -> dict:
|
||||||
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end."""
|
"""Zbiorcza oś czasu z technik (LOG-14): technique | significator | start | exact | end.
|
||||||
|
|
||||||
|
Z interpret=true dopina do zdarzeń interpretacje z warstwy danych (LOG-19, 1B->2B).
|
||||||
|
"""
|
||||||
from app.engine import houses as H
|
from app.engine import houses as H
|
||||||
from app.engine.models import ChartMoment
|
from app.engine.models import ChartMoment
|
||||||
from app.engine.timeline import build_timeline
|
from app.engine.timeline import build_timeline
|
||||||
@@ -196,8 +221,16 @@ def chart_timeline(req: TimelineRequest) -> dict:
|
|||||||
for p in engine.positions(natal):
|
for p in engine.positions(natal):
|
||||||
points[p.name] = p.longitude
|
points[p.name] = p.longitude
|
||||||
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
events = build_timeline(engine, natal, points, req.from_date, req.to_date, req.techniques)
|
||||||
return {"engine": engine.name, "from": req.from_date, "to": req.to_date,
|
|
||||||
"count": len(events), "events": events}
|
out = {"engine": engine.name, "from": req.from_date, "to": req.to_date}
|
||||||
|
if req.interpret:
|
||||||
|
from app.significators import interpret_events
|
||||||
|
try:
|
||||||
|
interpret_events(events, DataClient())
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
out["data_error"] = f"Warstwa danych niedostępna: {e}"
|
||||||
|
out.update(count=len(events), events=events)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
@@ -195,3 +197,61 @@ def build_report(
|
|||||||
"facets": facets,
|
"facets": facets,
|
||||||
})
|
})
|
||||||
return {"provider": provider, "objects": items}
|
return {"provider": provider, "objects": items}
|
||||||
|
|
||||||
|
|
||||||
|
def _event_tokens(event: dict) -> list[str]:
|
||||||
|
"""Tokeny bazy dla zdarzenia osi czasu (spięcie 1B→2B). Pierwszy = planeta."""
|
||||||
|
from app.engine.aspects import DB_TOKEN
|
||||||
|
|
||||||
|
if event.get("technique") == "solar_arc":
|
||||||
|
p = PLANET_ABBR.get(event.get("directed"))
|
||||||
|
a = DB_TOKEN.get(event.get("aspect"))
|
||||||
|
q = PLANET_ABBR.get(event.get("target")) # None dla Asc/MC (nie ma tokenu)
|
||||||
|
toks = []
|
||||||
|
if p:
|
||||||
|
toks.append("[" + p)
|
||||||
|
if a:
|
||||||
|
toks.append(a)
|
||||||
|
if q:
|
||||||
|
toks.append("[" + q)
|
||||||
|
return toks if p else []
|
||||||
|
if event.get("technique") == "profection":
|
||||||
|
lord = PLANET_ABBR.get(event.get("lord"))
|
||||||
|
sign = SIGN_TO_ABBR.get(event.get("sign"))
|
||||||
|
toks = []
|
||||||
|
if lord:
|
||||||
|
toks.append("[" + lord)
|
||||||
|
if sign:
|
||||||
|
toks.append("[" + sign)
|
||||||
|
return toks if lord else []
|
||||||
|
if event.get("technique") == "firdaria":
|
||||||
|
major = PLANET_ABBR.get(event.get("fd_major"))
|
||||||
|
sub = PLANET_ABBR.get(event.get("fd_sub"))
|
||||||
|
toks = []
|
||||||
|
if major:
|
||||||
|
toks.append("[" + major)
|
||||||
|
if sub:
|
||||||
|
toks.append("[" + sub)
|
||||||
|
return toks if major else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def interpret_events(events: list[dict], data: DataSource, limit: int = 4,
|
||||||
|
per_object_limit: int = 5000) -> list[dict]:
|
||||||
|
"""Dopina interpretacje z bazy do zdarzeń osi czasu (predykcyjne 1B → 2B).
|
||||||
|
|
||||||
|
Wyszukuje po tokenie planety zdarzenia i zawęża do wszystkich tokenów
|
||||||
|
(aspekt/druga planeta lub znak), z odsiewaniem szumu i duplikatów.
|
||||||
|
"""
|
||||||
|
for ev in events:
|
||||||
|
tokens = _event_tokens(ev)
|
||||||
|
if not tokens:
|
||||||
|
continue
|
||||||
|
raw = data.search(
|
||||||
|
key="significator", value=tokens[0], exact=False, limit=per_object_limit,
|
||||||
|
fields=["significator", "actioneffect", "topicresult", "bodypart"],
|
||||||
|
)
|
||||||
|
samples = _facet_samples(raw.get("rows", []), tokens)
|
||||||
|
ev["interpretations"] = samples[:limit]
|
||||||
|
ev["interpretations_count"] = len(samples)
|
||||||
|
return events
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Firdaria (LOG-11) — sekta, kolejność, długości okresów, podokresy."""
|
||||||
|
import datetime as dt
|
||||||
|
|
||||||
|
from app.engine.firdaria import DAY_ORDER, NIGHT_ORDER, YEARS, firdaria, is_day_birth
|
||||||
|
from app.significators import _event_tokens
|
||||||
|
|
||||||
|
BIRTH = dt.datetime(1984, 4, 30, 7, 35, tzinfo=dt.timezone.utc)
|
||||||
|
# horoskop referencyjny: Asc 112.18, MC 352.59, Sun 40.14 -> Day birth (notes3)
|
||||||
|
ASC, MC, SUN = 112.18, 352.59, 40.14
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_is_day_birth():
|
||||||
|
assert is_day_birth(SUN, ASC, MC) is True # notes3: "Day birth (Diurnal)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_night_birth_uses_night_order():
|
||||||
|
# Słońce po stronie IC (przeciwna półkula) -> noc
|
||||||
|
assert is_day_birth((SUN + 180) % 360, ASC, MC) is False
|
||||||
|
fd = firdaria(BIRTH, (SUN + 180) % 360, ASC, MC)
|
||||||
|
assert fd["sect"] == "night" and fd["order"] == NIGHT_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_diurnal_sequence_and_totals():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
assert fd["sect"] == "day" and fd["order"] == DAY_ORDER
|
||||||
|
lords = [p["lord"] for p in fd["periods"]]
|
||||||
|
assert lords == DAY_ORDER + ["North Node", "South Node"]
|
||||||
|
assert sum(p["years"] for p in fd["periods"]) == 75 # 70 + 3 + 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_planet_majors_have_seven_subperiods_summing_to_period():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for p in fd["periods"]:
|
||||||
|
if p["lord"] in YEARS:
|
||||||
|
assert len(p["sub"]) == 7
|
||||||
|
assert p["sub"][0]["lord"] == p["lord"] # sub zaczyna się od władcy okresu
|
||||||
|
assert p["sub"][0]["start"] == p["start"]
|
||||||
|
assert p["sub"][-1]["end"] == p["end"]
|
||||||
|
else:
|
||||||
|
assert "sub" not in p # węzły bez podokresów
|
||||||
|
|
||||||
|
|
||||||
|
def test_periods_are_contiguous():
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
for a, b in zip(fd["periods"], fd["periods"][1:]):
|
||||||
|
assert a["end"] == b["start"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_age_42_is_saturn_major():
|
||||||
|
# kumulatywnie: Su10 Ve8 Me13 Mo9 -> 40; Saturn 40-51 -> wiek 42 w Saturnie
|
||||||
|
fd = firdaria(BIRTH, SUN, ASC, MC)
|
||||||
|
saturn = next(p for p in fd["periods"] if p["lord"] == "Saturn")
|
||||||
|
assert saturn["start"].startswith("2024") and saturn["end"].startswith("2035")
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_firdaria():
|
||||||
|
ev = {"technique": "firdaria", "fd_major": "Saturn", "fd_sub": "Jupiter"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Ju"]
|
||||||
@@ -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,46 @@
|
|||||||
|
"""Spięcie osi czasu z bazą interpretacji (LOG-14 → 1B→2B)."""
|
||||||
|
from app.significators import _event_tokens, interpret_events
|
||||||
|
|
||||||
|
|
||||||
|
class FakeData:
|
||||||
|
def __init__(self, rows_by_value):
|
||||||
|
self.rows_by_value = rows_by_value
|
||||||
|
|
||||||
|
def search(self, key, value, exact, limit, fields=None):
|
||||||
|
rows = self.rows_by_value.get(value, [])
|
||||||
|
return {"provider": "fake", "total": len(rows), "rows": rows}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"}
|
||||||
|
assert _event_tokens(ev) == ["[Ve", "[conj", "[NN"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_solar_arc_to_angle_has_no_target_token():
|
||||||
|
ev = {"technique": "solar_arc", "directed": "Sun", "aspect": "square", "target": "MC"}
|
||||||
|
assert _event_tokens(ev) == ["[Su", "[sq"] # MC nie ma tokenu planety
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_tokens_profection():
|
||||||
|
ev = {"technique": "profection", "lord": "Saturn", "sign": "Capricorn"}
|
||||||
|
assert _event_tokens(ev) == ["[Sa", "[Cap"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_solar_return_has_no_tokens():
|
||||||
|
assert _event_tokens({"technique": "solar_return"}) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_interpret_attaches_matches_with_all_tokens():
|
||||||
|
events = [
|
||||||
|
{"technique": "solar_arc", "directed": "Venus", "aspect": "conjunction", "target": "North Node"},
|
||||||
|
{"technique": "solar_return"},
|
||||||
|
]
|
||||||
|
data = FakeData({"[Ve": [
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # wszystkie tokeny
|
||||||
|
{"significator": "[Ve [conj [Mo", "actioneffect": "inny"}, # brak [NN
|
||||||
|
{"significator": "[Ve [conj [NN", "actioneffect": "spotkanie losowe"}, # duplikat
|
||||||
|
]})
|
||||||
|
interpret_events(events, data)
|
||||||
|
assert events[0]["interpretations_count"] == 1 # duplikat odsiany, tylko z [NN
|
||||||
|
assert events[0]["interpretations"][0]["expanded"] == "Venus conjunction North Node"
|
||||||
|
assert "interpretations" not in events[1] # solar_return pominięty
|
||||||
@@ -56,3 +56,17 @@ class LogicClient:
|
|||||||
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
r = client.post(f"{self.base_url}/chart/report", json=payload)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
def timeline(
|
||||||
|
self, when_utc_iso: str, lat: float, lon: float,
|
||||||
|
from_date: str, to_date: str, interpret: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Oś czasu z technik (+interpretacje z bazy) — woła logic /chart/timeline."""
|
||||||
|
payload = {
|
||||||
|
"when_utc": when_utc_iso, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date, "interpret": interpret,
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=max(settings.http_timeout, 60.0)) as client:
|
||||||
|
r = client.post(f"{self.base_url}/chart/timeline", json=payload)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
@@ -105,7 +110,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)
|
||||||
@@ -132,6 +140,62 @@ def interpret_run(
|
|||||||
return templates.TemplateResponse(request, "interpret.html", ctx)
|
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- 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": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/timeline", response_class=HTMLResponse)
|
||||||
|
def timeline_run(
|
||||||
|
request: Request,
|
||||||
|
date: str = Form(...),
|
||||||
|
time: str = Form(...),
|
||||||
|
tz_offset: float = Form(0.0),
|
||||||
|
lat: float = Form(0.0),
|
||||||
|
lon: float = Form(0.0),
|
||||||
|
from_date: str = Form(...),
|
||||||
|
to_date: str = Form(...),
|
||||||
|
):
|
||||||
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||||||
|
"from_date": from_date, "to_date": to_date}
|
||||||
|
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.timeline(
|
||||||
|
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||||
|
from_date=from_date, to_date=to_date, interpret=True,
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
ctx["error"] = _logic_error(e)
|
||||||
|
except ValueError as e:
|
||||||
|
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||||||
|
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"}
|
||||||
|
|||||||
@@ -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) {
|
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>
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<nav>
|
<nav>
|
||||||
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
<a href="/" class="{% block nav_chart %}{% endblock %}">Horoskop</a>
|
||||||
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
<a href="/interpret" class="{% block nav_interp %}{% endblock %}">Interpretacje</a>
|
||||||
|
<a href="/timeline" class="{% block nav_timeline %}{% endblock %}">Kalendarz</a>
|
||||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -33,10 +33,12 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
@@ -88,6 +90,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>
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Kalendarz{% endblock %}
|
||||||
|
{% block nav_timeline %}active{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|
||||||
|
<form method="post" action="/timeline">
|
||||||
|
<div class="grid">
|
||||||
|
<label>Data urodzenia
|
||||||
|
<input type="date" name="date" value="{{ form.date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Godzina (lokalna)
|
||||||
|
<input type="time" name="time" value="{{ form.time or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Strefa (offset h)
|
||||||
|
<input type="number" name="tz_offset" step="0.25" value="{{ form.tz_offset if form.tz_offset is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Szerokość (lat)
|
||||||
|
<input type="number" name="lat" step="0.0001" value="{{ form.lat if form.lat is not none else 0 }}">
|
||||||
|
</label>
|
||||||
|
<label>Długość (lon)
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
<label>Zakres do
|
||||||
|
<input type="date" name="to_date" value="{{ form.to_date or '' }}" required>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||||
|
<button type="submit">Pokaż kalendarz</button>
|
||||||
|
<span id="geoNote" class="muted small"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
{% if result %}
|
||||||
|
{% if result.data_error %}<div class="error">{{ result.data_error }}</div>{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
|
zdarzeń: {{ result.count }} · zakres {{ result.from }} → {{ result.to }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for e in result.events %}
|
||||||
|
<div class="sig-item">
|
||||||
|
<div class="sig-head">
|
||||||
|
<span class="badge">{{ e.exact }}</span>
|
||||||
|
<span class="badge">{{ e.technique }}</span>
|
||||||
|
<strong>{{ e.significator }}</strong>
|
||||||
|
<span class="muted small">(okno {{ e.start }} → {{ e.end }})</span>
|
||||||
|
</div>
|
||||||
|
{% if e.interpretations %}
|
||||||
|
<table class="samples">
|
||||||
|
<tbody>
|
||||||
|
{% for s in e.interpretations %}
|
||||||
|
<tr><td class="sig nowrap" title="{{ s.significator }}">{{ s.expanded }}</td><td>{{ s.effect }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if e.interpretations_count > e.interpretations | length %}
|
||||||
|
<div class="muted small">…i {{ e.interpretations_count - (e.interpretations | length) }} więcej</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif e.technique != 'solar_return' %}
|
||||||
|
<div class="muted small">brak dopasowań w bazie</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script src="/static/now.js"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user