Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ebce816cd | |||
| 52b7c20c2a | |||
| 9b1e4dbb20 | |||
| 4bdfb673cc |
@@ -35,7 +35,8 @@ def _shift_pos(pdict: dict, off: float) -> None:
|
||||
|
||||
|
||||
def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str = H.WHOLE_SIGN,
|
||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL) -> dict:
|
||||
lots_method: str = "degree", zodiac: str = Z.TROPICAL,
|
||||
house_systems: list[str] | None = None) -> dict:
|
||||
from app.engine import glyphs as GL
|
||||
from app.engine.aspects import find_aspects
|
||||
from app.engine.houses import mean_obliquity
|
||||
@@ -94,6 +95,16 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
system = house_system if house_system in H.SYSTEMS else H.WHOLE_SIGN
|
||||
cusp_list = H.cusps(asc, mc, system) # tropikalne — geometria domów jest niezmiennicza
|
||||
|
||||
def _cusps_out(cl: list[float]) -> list[dict]:
|
||||
"""Cuspy → wiersze pod UI/kosmogram: znak, stopień w znaku, długość, glif."""
|
||||
return [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||
"in_sign": in_sign(norm360(c - off)),
|
||||
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||
for i, c in enumerate(cl)
|
||||
]
|
||||
|
||||
result["house_system"] = system
|
||||
result["angles"] = {
|
||||
"Asc": _fmt("Asc", asc, off),
|
||||
@@ -103,16 +114,25 @@ def build_chart(engine: EphemerisEngine, moment: ChartMoment, house_system: str
|
||||
}
|
||||
for a in result["angles"].values(): # glif znaku osi (LOG-22)
|
||||
a["sign_glyph"] = GL.sign_glyph(a["sign"])
|
||||
result["cusps"] = [
|
||||
{"house": i + 1, "sign": SIGNS[sign_index(norm360(c - off))],
|
||||
"in_sign": in_sign(norm360(c - off)),
|
||||
"decimal": round(norm360(c - off), 6), # długość cuspu — pod kosmogram (PRE-12)
|
||||
"sign_glyph": GL.sign_glyph(SIGNS[sign_index(norm360(c - off))])}
|
||||
for i, c in enumerate(cusp_list)
|
||||
]
|
||||
result["cusps"] = _cusps_out(cusp_list) # PRYMARNY system — pod kosmogram i wstecz
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict["house"] = H.assign_house(obj.longitude, cusp_list) # dom po długości tropikalnej
|
||||
|
||||
# Wiele systemów domów NARAZ (PRE-05/LOG-05) — do porównania obok siebie.
|
||||
# Osie (Asc/MC) są wspólne; różni się tylko PODZIAŁ na domy. Prymarny zostaje
|
||||
# w `cusps`/`house_system` (kosmogram i wstecz), a `house_systems` niesie pełen
|
||||
# zestaw; `positions[].houses[system]` mówi, w którym domu obiekt siedzi wg
|
||||
# danego systemu. Dokładamy tylko gdy poproszono o więcej niż jeden.
|
||||
requested = [system] + [s for s in (house_systems or []) if s in H.SYSTEMS]
|
||||
ordered = list(dict.fromkeys(requested)) # prymarny pierwszy, bez duplikatów
|
||||
if len(ordered) > 1:
|
||||
result["house_systems"] = []
|
||||
for s in ordered:
|
||||
cl = cusp_list if s == system else H.cusps(asc, mc, s)
|
||||
result["house_systems"].append({"system": s, "cusps": _cusps_out(cl)})
|
||||
for pdict, obj in zip(result["positions"], positions):
|
||||
pdict.setdefault("houses", {})[s] = H.assign_house(obj.longitude, cl)
|
||||
|
||||
# Lots (LOG-08) — wymagają Asc i sekty (dzień/noc)
|
||||
from app.engine.firdaria import is_day_birth
|
||||
from app.engine.lots import compute_lots
|
||||
|
||||
@@ -43,7 +43,8 @@ class PositionsRequest(BaseModel):
|
||||
lat: float = 0.0
|
||||
lon: float = 0.0
|
||||
objects: list[str] | None = None
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry
|
||||
house_system: str = "whole_sign" # whole_sign | equal | porphyry (PRYMARNY — pod kosmogram)
|
||||
house_systems: list[str] | None = None # PRE-05: dodatkowe systemy do porównania obok
|
||||
stations: bool = False # licz stacje (LOG-03; wolniejsze — root-findy)
|
||||
tables: bool = False # tabele dodatkowe (LOG-23; szuka wschodu/zachodu)
|
||||
zodiac: str = "tropical" # LOG-04: tropical | sidereal_{lahiri,fagan_bradley,krishnamurti} | draconic
|
||||
@@ -67,7 +68,8 @@ def chart_positions(req: PositionsRequest) -> dict:
|
||||
engine = get_engine()
|
||||
moment = ChartMoment(when_utc=req.when_utc, lat=req.lat, lon=req.lon)
|
||||
try:
|
||||
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac)
|
||||
chart = build_chart(engine, moment, req.house_system, zodiac=req.zodiac,
|
||||
house_systems=req.house_systems)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
if req.stations:
|
||||
|
||||
@@ -25,3 +25,44 @@ def test_house_systems_available(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, system)
|
||||
assert chart["house_system"] == system
|
||||
assert len(chart["cusps"]) == 12
|
||||
|
||||
|
||||
# ── wiele systemów domów naraz (PRE-05/LOG-05) ──
|
||||
|
||||
def test_multiple_house_systems_side_by_side(own_engine, reference_moment):
|
||||
"""Prymarny zostaje w cusps/house_system; `house_systems` niesie pełen zestaw
|
||||
do porównania, z prymarnym NA POCZĄTKU i bez duplikatów."""
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign",
|
||||
house_systems=["equal", "porphyry", "whole_sign"])
|
||||
assert chart["house_system"] == "whole_sign" # prymarny bez zmian
|
||||
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal", "porphyry"]
|
||||
for h in chart["house_systems"]:
|
||||
assert len(h["cusps"]) == 12
|
||||
|
||||
|
||||
def test_single_system_has_no_comparison_block(own_engine, reference_moment):
|
||||
"""Bez dodatkowych systemów nie zaśmiecamy wyniku."""
|
||||
assert "house_systems" not in build_chart(own_engine, reference_moment, "whole_sign")
|
||||
|
||||
|
||||
def test_object_gets_a_house_in_every_system(own_engine, reference_moment):
|
||||
"""Sedno porównania: każdy obiekt ma dom w KAŻDYM systemie, a prymarny jest
|
||||
spójny z polem `.house`."""
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign", house_systems=["porphyry"])
|
||||
sun = next(p for p in chart["positions"] if p["name"] == "Sun")
|
||||
assert set(sun["houses"]) == {"whole_sign", "porphyry"}
|
||||
assert sun["houses"]["whole_sign"] == sun["house"]
|
||||
|
||||
|
||||
def test_primary_always_first_even_if_not_listed(own_engine, reference_moment):
|
||||
chart = build_chart(own_engine, reference_moment, "equal", house_systems=["porphyry"])
|
||||
systems = [h["system"] for h in chart["house_systems"]]
|
||||
assert systems[0] == "equal" and "porphyry" in systems
|
||||
|
||||
|
||||
def test_unknown_extra_system_is_ignored(own_engine, reference_moment):
|
||||
"""Nieobsługiwany system (np. placidus — dojdzie przez swisseph osobno) jest
|
||||
po prostu pomijany, nie wywala horoskopu."""
|
||||
chart = build_chart(own_engine, reference_moment, "whole_sign",
|
||||
house_systems=["placidus", "equal"])
|
||||
assert [h["system"] for h in chart["house_systems"]] == ["whole_sign", "equal"]
|
||||
|
||||
@@ -54,6 +54,7 @@ class LogicClient:
|
||||
stations: bool = False,
|
||||
zodiac: str = "tropical",
|
||||
tables: bool = False,
|
||||
house_systems: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Pełny horoskop dla danego momentu — woła logic /chart/positions."""
|
||||
payload = {
|
||||
@@ -62,6 +63,7 @@ class LogicClient:
|
||||
"lon": lon,
|
||||
"objects": objects,
|
||||
"house_system": house_system,
|
||||
"house_systems": house_systems,
|
||||
"stations": stations,
|
||||
"zodiac": zodiac,
|
||||
"tables": tables,
|
||||
|
||||
@@ -79,12 +79,14 @@ def chart_compute(
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
|
||||
stations: bool = Form(False),
|
||||
zodiac: str = Form("tropical"),
|
||||
tables: bool = Form(False),
|
||||
):
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system, "stations": stations,
|
||||
"lat": lat, "lon": lon, "house_system": house_system,
|
||||
"house_systems": house_systems, "stations": stations,
|
||||
"zodiac": zodiac, "tables": tables}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
@@ -92,7 +94,8 @@ def chart_compute(
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, stations=stations, zodiac=zodiac, tables=tables,
|
||||
house_system=house_system, house_systems=house_systems,
|
||||
stations=stations, zodiac=zodiac, tables=tables,
|
||||
)
|
||||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||
@@ -125,24 +128,32 @@ def compile_build(
|
||||
lat: float = Form(0.0),
|
||||
lon: float = Form(0.0),
|
||||
house_system: str = Form("whole_sign"),
|
||||
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
|
||||
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||||
zodiac: str = Form("tropical"),
|
||||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||||
):
|
||||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||||
|
||||
Dlaczego horoskop liczymy ponownie, zamiast go zapamiętywać: to czysta funkcja
|
||||
danych wejściowych — tanio ją powtórzyć, a odpada trzymanie w przeglądarce
|
||||
dużego wyniku, który mógłby się rozjechać z aktualnym formularzem.
|
||||
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||||
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
|
||||
wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej
|
||||
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
|
||||
zakładkami przez formsync.
|
||||
"""
|
||||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||||
"lat": lat, "lon": lon, "house_system": house_system, "zodiac": zodiac}
|
||||
"lat": lat, "lon": lon, "house_system": house_system,
|
||||
"house_systems": house_systems, "stations": stations,
|
||||
"zodiac": zodiac, "tables": tables}
|
||||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||||
try:
|
||||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||||
ctx["moment"] = label
|
||||
ctx["result"] = logic.positions(
|
||||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||||
house_system=house_system, zodiac=zodiac,
|
||||
house_system=house_system, house_systems=house_systems,
|
||||
stations=stations, zodiac=zodiac, tables=tables,
|
||||
)
|
||||
from app import chartwheel
|
||||
ctx["wheel_svg"] = chartwheel.render(ctx["result"])
|
||||
@@ -186,6 +197,8 @@ def compile_pdf(payload: dict):
|
||||
when_utc_iso=iso_utc,
|
||||
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
|
||||
house_system=str(data.get("house_system") or "whole_sign"),
|
||||
house_systems=[s for s in (data.get("house_systems") or []) if s],
|
||||
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
|
||||
zodiac=str(data.get("zodiac") or "tropical"),
|
||||
)
|
||||
from app import chartwheel
|
||||
@@ -442,6 +455,20 @@ def geocode_reverse(lat: float, lon: float):
|
||||
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||||
|
||||
|
||||
@app.get("/timezone")
|
||||
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
|
||||
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
|
||||
|
||||
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
|
||||
gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset."""
|
||||
from app import timezone as tzmod
|
||||
|
||||
res = tzmod.resolve(lat, lon, date, time)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
|
||||
return res
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok", "layer": "presentation"}
|
||||
|
||||
@@ -93,10 +93,23 @@
|
||||
var el = document.querySelector('[name=' + name + ']');
|
||||
return el ? el.value : '';
|
||||
}
|
||||
function checked(name) {
|
||||
var el = document.querySelector('[name=' + name + ']');
|
||||
return !!(el && el.checked);
|
||||
}
|
||||
function multi(name) {
|
||||
var out = [];
|
||||
document.querySelectorAll('[name=' + name + ']:checked').forEach(function (el) { out.push(el.value); });
|
||||
return out;
|
||||
}
|
||||
// Opcje (stacje, tabele, porównanie domów) MUSZĄ jechać do PDF-a — inaczej
|
||||
// PDF liczyłby okrojony horoskop i miałby braki względem podsumowania.
|
||||
return {
|
||||
date: v('date'), time: v('time'), tz_offset: v('tz_offset'),
|
||||
lat: v('lat'), lon: v('lon'),
|
||||
house_system: v('house_system'), zodiac: v('zodiac'),
|
||||
house_systems: multi('house_systems'),
|
||||
stations: checked('stations'), tables: checked('tables'),
|
||||
place: (document.querySelector('#geoSearch') || {}).value || ''
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,18 +20,31 @@
|
||||
|
||||
// Pola wspólne dla zakładek. Nazwa pola formularza -> jak je znaleźć.
|
||||
// `geoSearch` (nazwa miejsca) nie ma atrybutu name — szukamy po id.
|
||||
// type: brak = zwykła wartość (.value); 'check' = pojedynczy checkbox (.checked);
|
||||
// 'multi' = kilka checkboxów o tej samej nazwie (lista zaznaczonych wartości).
|
||||
// Opcje (stacje, tabele, porównanie domów) są tu, żeby wybór z „Horoskopu"
|
||||
// wędrował na „Skompiluj" — inaczej podsumowanie liczyłoby się bez nich.
|
||||
var FIELDS = [
|
||||
{ key: 'person', sel: 'input[name=person]' },
|
||||
{ key: 'date', sel: 'input[name=date]' },
|
||||
{ key: 'time', sel: 'input[name=time]' },
|
||||
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
|
||||
{ key: 'lat', sel: 'input[name=lat]' },
|
||||
{ key: 'lon', sel: 'input[name=lon]' },
|
||||
{ key: 'house_system', sel: 'select[name=house_system]' },
|
||||
{ key: 'zodiac', sel: 'select[name=zodiac]' },
|
||||
{ key: 'place', sel: '#geoSearch' }
|
||||
{ key: 'person', sel: 'input[name=person]' },
|
||||
{ key: 'date', sel: 'input[name=date]' },
|
||||
{ key: 'time', sel: 'input[name=time]' },
|
||||
{ key: 'tz_offset', sel: 'input[name=tz_offset]' },
|
||||
{ key: 'lat', sel: 'input[name=lat]' },
|
||||
{ key: 'lon', sel: 'input[name=lon]' },
|
||||
{ key: 'house_system', sel: 'select[name=house_system]' },
|
||||
{ key: 'zodiac', sel: 'select[name=zodiac]' },
|
||||
{ key: 'place', sel: '#geoSearch' },
|
||||
{ key: 'stations', sel: 'input[name=stations]', type: 'check' },
|
||||
{ key: 'tables', sel: 'input[name=tables]', type: 'check' },
|
||||
{ key: 'house_systems', name: 'house_systems', type: 'multi' }
|
||||
];
|
||||
|
||||
function elementsOf(f) {
|
||||
if (f.type === 'multi') return Array.prototype.slice.call(document.querySelectorAll('[name=' + f.name + ']'));
|
||||
var el = document.querySelector(f.sel);
|
||||
return el ? [el] : [];
|
||||
}
|
||||
|
||||
function load() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || '{}') || {};
|
||||
@@ -50,21 +63,35 @@
|
||||
|
||||
function apply(state) {
|
||||
FIELDS.forEach(function (f) {
|
||||
var el = document.querySelector(f.sel);
|
||||
if (!el) return;
|
||||
var els = elementsOf(f);
|
||||
if (!els.length) return;
|
||||
var v = state[f.key];
|
||||
if (v === undefined || v === null || v === '') return;
|
||||
if (el.value !== v) el.value = v;
|
||||
if (v === undefined || v === null) return;
|
||||
if (f.type === 'multi') {
|
||||
if (!Array.isArray(v)) return;
|
||||
els.forEach(function (el) { el.checked = v.indexOf(el.value) !== -1; });
|
||||
} else if (f.type === 'check') {
|
||||
els[0].checked = !!v;
|
||||
} else {
|
||||
if (v === '') return;
|
||||
if (els[0].value !== v) els[0].value = v;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collect() {
|
||||
var state = load();
|
||||
FIELDS.forEach(function (f) {
|
||||
var el = document.querySelector(f.sel);
|
||||
if (!el) return; // pola nieobecnego na tej zakładce nie kasujemy
|
||||
if (el.value === '' || el.value === null) return;
|
||||
state[f.key] = el.value;
|
||||
var els = elementsOf(f);
|
||||
if (!els.length) return; // pola nieobecnego na tej zakładce nie kasujemy
|
||||
if (f.type === 'multi') {
|
||||
state[f.key] = els.filter(function (el) { return el.checked; }).map(function (el) { return el.value; });
|
||||
} else if (f.type === 'check') {
|
||||
state[f.key] = els[0].checked;
|
||||
} else {
|
||||
if (els[0].value === '' || els[0].value === null) return;
|
||||
state[f.key] = els[0].value;
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}
|
||||
@@ -72,12 +99,12 @@
|
||||
// 1) odtwórz to, co użytkownik wpisał gdzie indziej
|
||||
apply(load());
|
||||
|
||||
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list i kalendarzy)
|
||||
// 2) zapisuj każdą zmianę (input dla wpisywania, change dla list, checkboxów, kalendarzy)
|
||||
FIELDS.forEach(function (f) {
|
||||
var el = document.querySelector(f.sel);
|
||||
if (!el) return;
|
||||
['input', 'change'].forEach(function (ev) {
|
||||
el.addEventListener(ev, function () { save(collect()); });
|
||||
elementsOf(f).forEach(function (el) {
|
||||
['input', 'change'].forEach(function (ev) {
|
||||
el.addEventListener(ev, function () { save(collect()); });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,21 @@
|
||||
var resultsEl = document.getElementById('geoResults');
|
||||
var mapEl = document.getElementById('map');
|
||||
|
||||
// ---- strefa czasowa z lokalizacji (PRE-03) ----
|
||||
// Offset GMT liczymy ze współrzędnych + daty (DST-świadomie), żeby użytkownik
|
||||
// nie musiał go znać ani pamiętać o czasie letnim. Podpowiedź trafia do pola
|
||||
// tz_offset (PRE-19), które zostaje edytowalne — to pomoc, nie kaganiec.
|
||||
var tzEl = document.querySelector('input[name=tz_offset]');
|
||||
var dateEl = document.querySelector('input[name=date]');
|
||||
var timeEl = document.querySelector('input[name=time]');
|
||||
var tzNote = null;
|
||||
if (tzEl && tzEl.parentNode) {
|
||||
tzNote = document.createElement('span');
|
||||
tzNote.className = 'muted small';
|
||||
tzNote.id = 'tzNote';
|
||||
tzEl.parentNode.appendChild(tzNote);
|
||||
}
|
||||
|
||||
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); };
|
||||
@@ -53,6 +68,28 @@
|
||||
marker.setLatLng([lat, lon]);
|
||||
if (!fromMap) map.setView([lat, lon], Math.max(map.getZoom(), 12));
|
||||
if (fromMap) reverseName(lat, lon);
|
||||
resolveTz(); // nowe współrzędne → odśwież strefę/offset
|
||||
}
|
||||
|
||||
// Współrzędne + data → offset GMT (DST-świadomy). Wypełnia tz_offset i pokazuje,
|
||||
// jaką strefę wykryto. Bez pola/daty/biblioteki po cichu pasuje — zostaje
|
||||
// ręczny offset. Offset trzymamy potem jako stałą liczbę: to on decyduje przy
|
||||
// przeliczaniu, więc dokładne współrzędne nie przerzucą już czasu letniego.
|
||||
function resolveTz() {
|
||||
if (!tzEl || !dateEl || !dateEl.value) return;
|
||||
var lat = curLat(), lon = curLon();
|
||||
if (!lat && !lon) return; // 0,0 = brak lokalizacji
|
||||
var url = '/timezone?lat=' + lat + '&lon=' + lon +
|
||||
'&date=' + encodeURIComponent(dateEl.value) +
|
||||
'&time=' + encodeURIComponent((timeEl && timeEl.value) || '12:00');
|
||||
fetch(url)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (d) {
|
||||
if (!d) { if (tzNote) tzNote.textContent = ''; return; }
|
||||
setVal(tzEl, d.offset); // change → formsync podłapie
|
||||
if (tzNote) tzNote.textContent = 'Wykryto: ' + d.label;
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
function reverseName(lat, lon) {
|
||||
@@ -130,6 +167,16 @@
|
||||
marker.setLatLng([curLat(), curLon()]);
|
||||
map.setView([curLat(), curLon()], Math.max(map.getZoom(), 12));
|
||||
reverseName(curLat(), curLon());
|
||||
resolveTz(); // „Tu i teraz" → nowe miejsce → nowa strefa
|
||||
});
|
||||
|
||||
// Zmiana daty przelicza offset — czas letni zależy od daty (inny w czerwcu
|
||||
// niż w styczniu), więc bez tego offset zostałby z poprzedniej pory roku.
|
||||
if (dateEl) dateEl.addEventListener('change', resolveTz);
|
||||
|
||||
// Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony
|
||||
// (0 lub pusto) — żeby nie nadpisać wartości, którą użytkownik wpisał ręcznie
|
||||
// i wysłał. Przy każdej późniejszej zmianie miejsca/daty już aktualizujemy.
|
||||
if (tzEl && (tzEl.value === '' || parseFloat(tzEl.value) === 0)) resolveTz();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
{# Tabele wyniku horoskopu — używane przez „Skompiluj" (/compile), żeby
|
||||
podsumowanie pokazywało dokładnie to, co policzono (regresja 2026-07-28:
|
||||
podsumowanie miało braki). Skopiowane 1:1 z sekcji tabel na „Horoskop" (/).
|
||||
TODO: przełączyć też chart.html na ten include, żeby oba widoki nie mogły się
|
||||
rozjechać — na razie pilnować ręcznie, że są zgodne. Rysunki (koło/aspektarian/
|
||||
deklinacja/antyscja) renderuje rodzic; tu są same tabele. Każda sekcja pokazuje
|
||||
się tylko, gdy jej dane są w wyniku (opcja policzona → sekcja jest). #}
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||
{% set a = result.angles[key] %}
|
||||
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
|
||||
{% if result.house_systems and result.house_systems | length > 1 %}
|
||||
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
|
||||
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, hs.system) }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
{% for i in range(12) %}
|
||||
<tr><td>{{ i + 1 }}</td>
|
||||
{% for hs in result.house_systems %}
|
||||
<td class="mono"><span class="glyph">{{ hs.cusps[i].sign_glyph }}</span> {{ hs.cusps[i].in_sign }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th title="Symbol astrologiczny (LOG-22)">Sym.</th>
|
||||
<th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th><th>Prędkość °/d</th>
|
||||
<th title="Deklinacja — odległość od równika niebieskiego. OOB = poza zakresem Słońca">Dekl.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in result.positions %}
|
||||
<tr>
|
||||
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.sign }}</td>
|
||||
<td class="mono">{{ p.in_sign }}</td>
|
||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||
<td class="mono">{{ '%+.4f' | format(p.speed) }}</td>
|
||||
<td class="mono">{% if p.declination is defined %}{{ '%+.2f°' | format(p.declination) }}{% if p.out_of_bounds %} <span class="badge" title="Out of bounds — deklinacja poza zakresem Słońca">OOB</span>{% endif %}{% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if result.lots %}
|
||||
<div class="meta">Lots hermetyczne ({{ result.lots | length }}) · sekta: <strong>{{ result.sect }}</strong></div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Lot</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Formuła</th></tr></thead>
|
||||
<tbody>
|
||||
{% for l in result.lots %}
|
||||
<tr><td>{{ l.name }}</td><td>{{ l.sign }}</td><td class="mono">{{ l.in_sign }}</td><td>{{ l.house }}</td><td class="muted small">{{ l.formula }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% set with_stations = result.positions | selectattr('stations', 'defined') | list %}
|
||||
{% if with_stations %}
|
||||
<div class="meta">Stacje planet (poprzednia / następna; <span class="badge">blisko</span> = mniej niż 7 dni)</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Planeta</th><th>Poprzednia</th><th>Następna</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in with_stations %}
|
||||
<tr>
|
||||
<td>{{ p.name }}{% if p.stations.station_soon %} <span class="badge">blisko</span>{% endif %}</td>
|
||||
<td class="mono">{% if p.stations.prev %}{{ p.stations.prev.type }} · {{ p.stations.prev.date }} · {{ p.stations.prev.degree }} ({{ p.stations.prev.days }} d){% else %}—{% endif %}</td>
|
||||
<td class="mono">{% if p.stations.next %}{{ p.stations.next.type }} · {{ p.stations.next.date }} · {{ p.stations.next.degree }} (+{{ p.stations.next.days }} d){% else %}—{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.aspects %}
|
||||
<div class="meta">Aspekty główne ({{ result.aspects | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt 1</th><th>Aspekt</th><th>Obiekt 2</th><th>Orb</th><th title="A = aplikacyjny (dokładność nastąpi), S = separacyjny (już minęła)">A/S</th></tr></thead>
|
||||
<tbody>
|
||||
{% for a in result.aspects %}
|
||||
<tr><td>{{ a.obj1 }}</td><td>{% if a.glyph %}<span class="glyph">{{ a.glyph }}</span> {% endif %}{{ a.aspect }}</td><td>{{ a.obj2 }}</td><td class="mono">{{ '%.2f'|format(a.orb) }}°</td><td>{{ a['as'] if a['as'] is defined else '—' }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.parallels %}
|
||||
<div class="meta" title="Ciała na tej samej (paralela) lub przeciwnej (kontrparalela) deklinacji — działają jak koniunkcja / opozycja poza ekliptyką">Paralele deklinacji ({{ result.parallels | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th><th>Dekl.</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in result.parallels %}
|
||||
<tr>
|
||||
<td>{{ r.obj1 }}</td>
|
||||
<td>{{ 'paralela' if r.type == 'parallel' else 'kontrparalela' }}</td>
|
||||
<td>{{ r.obj2 }}</td>
|
||||
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||
<td class="mono">{{ '%+.2f'|format(r.dec1) }} / {{ '%+.2f'|format(r.dec2) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.antiscia %}
|
||||
<div class="meta" title="Odbicie względem osi przesileń (antyscja) lub równonocy (kontrantyscja) — punkty „dzielące” tę samą długość dnia">Antyscja ({{ result.antiscia | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt 1</th><th>Rodzaj</th><th>Obiekt 2</th><th>Orb</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in result.antiscia %}
|
||||
<tr>
|
||||
<td>{{ r.obj1 }}</td>
|
||||
<td>{{ 'antyscja' if r.type == 'antiscion' else 'kontrantyscja' }}</td>
|
||||
<td>{{ r.obj2 }}</td>
|
||||
<td class="mono">{{ '%.2f'|format(r.orb) }}°</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if result.tables %}
|
||||
{% set tb = result.tables %}
|
||||
<div class="meta">Tabele dodatkowe (LOG-23)</div>
|
||||
|
||||
{% set bal = tb.tally.with_modern_10_plus_asc %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Bilans (10 planet + Asc)</th><th colspan="4">Rozkład</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Żywioły</td>
|
||||
{% for e, n in bal.elements.items() %}
|
||||
<td>{{ tb.tally.labels.elements[e] }}: <strong>{{ n }}</strong></td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
<tr><td>Jakości</td>
|
||||
{% for q, n in bal.qualities.items() %}
|
||||
<td>{{ tb.tally.labels.qualities[q] }}: <strong>{{ n }}</strong></td>
|
||||
{% endfor %}
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if tb.tally.missing_elements or tb.tally.missing_qualities %}
|
||||
<p class="muted small">Brak:
|
||||
{% for e in tb.tally.missing_elements %}<strong>{{ tb.tally.labels.elements[e] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||
{% for q in tb.tally.missing_qualities %}<strong>{{ tb.tally.labels.qualities[q] }}</strong>{{ ", " if not loop.last }}{% endfor %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.moon_phase %}
|
||||
<p class="muted small">
|
||||
<strong>Faza Księżyca:</strong> {{ tb.moon_phase.phase_pl }} ·
|
||||
elongacja {{ '%.2f'|format(tb.moon_phase.angle) }}° ·
|
||||
oświetlenie {{ '%.1f'|format(tb.moon_phase.illumination * 100) }}% ·
|
||||
{{ 'przybywa' if tb.moon_phase.waxing else 'ubywa' }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.planetary_hours %}
|
||||
{% set ph = tb.planetary_hours %}
|
||||
<p class="muted small">
|
||||
<strong>Godziny planetarne:</strong> władca dnia {{ ph.day_ruler }} ·
|
||||
{{ ph.hour_number }}. godzina ({{ ph.period }}), władca {{ ph.hour_ruler }} ·
|
||||
godzina trwa {{ ph.hour_length_minutes }} min
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.prenatal_syzygy %}
|
||||
{% set s = tb.prenatal_syzygy %}
|
||||
<p class="muted small">
|
||||
<strong>Syzygia prenatalna:</strong> {{ s.type_pl }} {{ s.when_utc }}
|
||||
({{ s.days_before_birth }} dni przed) w {{ s.in_sign }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% if tb.critical_degrees %}
|
||||
<div class="meta">Stopnie krytyczne</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Obiekt</th><th>Pozycja</th><th>Uwaga</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in tb.critical_degrees %}
|
||||
<tr><td>{{ c.name }}</td><td class="mono">{{ c.in_sign }}</td>
|
||||
<td class="muted small">{{ c.flags | join('; ') }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<details class="loc">
|
||||
<summary>Podziały: dwunastniki (D12) i nawamsa (D9)</summary>
|
||||
<table>
|
||||
<thead><tr><th>Obiekt</th><th>D12</th><th>D9</th></tr></thead>
|
||||
<tbody>
|
||||
{% for d in tb.divisional %}
|
||||
<tr><td>{{ d.name }}</td><td class="mono">{{ d.d12_in_sign }}</td>
|
||||
<td class="mono">{{ d.d9_in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if result.cusps %}
|
||||
<details class="loc">
|
||||
<summary>Cusps domów ({{ result.house_system }})</summary>
|
||||
<table>
|
||||
<thead><tr><th>Dom</th><th>Znak</th><th>Cusp</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in result.cusps %}
|
||||
<tr><td>{{ c.house }}</td><td>{{ c.sign }}</td><td class="mono">{{ c.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
{% endif %}
|
||||
@@ -57,6 +57,13 @@
|
||||
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||
</div>
|
||||
<div class="opts" title="Domy dzielą to samo niebo inaczej. Zaznacz kilka, by porównać kuspy obok siebie (PRE-05). Kosmogram rysuje system wybrany wyżej.">
|
||||
{% set chosen = form.house_systems or [] %}
|
||||
<span class="muted small">Porównaj systemy domów:</span>
|
||||
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
|
||||
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
|
||||
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
|
||||
</div>
|
||||
{% if location_label %}<p class="muted small">Wstępnie wpisano lokalizację: <strong>{{ location_label }}</strong> ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".</p>{% endif %}
|
||||
<div class="actions">
|
||||
<button type="button" id="nowBtn" class="ghost">Tu i teraz</button>
|
||||
@@ -104,6 +111,24 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{# Porównanie systemów domów (PRE-05) — te same osie, inny podział na domy #}
|
||||
{% if result.house_systems and result.house_systems | length > 1 %}
|
||||
{% set HSN = {'whole_sign': 'Whole Sign', 'equal': 'Equal', 'porphyry': 'Porphyry'} %}
|
||||
<div class="meta" title="Osie (Asc/MC) są wspólne — różni się podział na domy. Kosmogram rysuje pierwszy z listy.">Porównanie systemów domów ({{ result.house_systems | length }})</div>
|
||||
<table class="angles">
|
||||
<thead><tr><th>Dom</th>{% for hs in result.house_systems %}<th>{{ HSN.get(hs.system, hs.system) }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>
|
||||
{% for i in range(12) %}
|
||||
<tr><td>{{ i + 1 }}</td>
|
||||
{% for hs in result.house_systems %}
|
||||
<td class="mono"><span class="glyph">{{ hs.cusps[i].sign_glyph }}</span> {{ hs.cusps[i].in_sign }}</td>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -55,6 +55,21 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{# Te same opcje co na „Horoskop" — synchronizowane między zakładkami (formsync),
|
||||
żeby podsumowanie liczyło się z tym, co wybrano przy horoskopie (nie okrojone). #}
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="stations" value="true" {{ 'checked' if form.stations else '' }}>
|
||||
licz stacje planet (wolniejsze)</label>
|
||||
<label><input type="checkbox" name="tables" value="true" {{ 'checked' if form.tables else '' }}>
|
||||
tabele dodatkowe: żywioły, faza Księżyca, godziny planetarne (wolniejsze)</label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
{% set chosen = form.house_systems or [] %}
|
||||
<span class="muted small">Porównaj systemy domów:</span>
|
||||
<label><input type="checkbox" name="house_systems" value="whole_sign" {{ 'checked' if 'whole_sign' in chosen else '' }}> Whole Sign</label>
|
||||
<label><input type="checkbox" name="house_systems" value="equal" {{ 'checked' if 'equal' in chosen else '' }}> Equal</label>
|
||||
<label><input type="checkbox" name="house_systems" value="porphyry" {{ 'checked' if 'porphyry' in chosen else '' }}> Porphyry</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">Złóż podsumowanie</button>
|
||||
<button type="button" id="pdfBtn" class="ghost"
|
||||
@@ -106,34 +121,12 @@ zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie t
|
||||
{% endif %}
|
||||
|
||||
{# ── 3. Dane policzone ────────────────────────────────────────────── #}
|
||||
{# WSPÓLNE tabele (ten sam plik co „Horoskop") — podsumowanie pokazuje dokładnie
|
||||
to, co policzono: porównanie domów, stacje, żywioły itd., jeśli były wybrane. #}
|
||||
<div class="meta">Horoskop · silnik {{ result.engine }}
|
||||
{% if result.house_system %}· domy {{ result.house_system }}{% endif %}
|
||||
{% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}</div>
|
||||
<table>
|
||||
<thead><tr><th>Sym.</th><th>Obiekt</th><th>Znak</th><th>W znaku</th><th>Dom</th><th>Kier.</th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in result.positions %}
|
||||
<tr>
|
||||
<td class="glyph">{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}</td>
|
||||
<td>{{ p.name }}</td><td>{{ p.sign }}</td><td class="mono">{{ p.in_sign }}</td>
|
||||
<td>{{ p.house if p.house is defined else '—' }}</td>
|
||||
<td class="{{ 'retro' if p.direction == 'Rx' else '' }}">{{ p.direction }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{% if result.angles %}
|
||||
<table class="angles">
|
||||
<thead><tr><th>Oś</th><th>Znak</th><th>W znaku</th></tr></thead>
|
||||
<tbody>
|
||||
{% for key in ["Asc", "MC", "Dsc", "IC"] %}
|
||||
{% set a = result.angles[key] %}
|
||||
<tr><td>{{ a.name }}</td><td>{{ a.sign }}</td><td class="mono">{{ a.in_sign }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% include "_result_tables.html" %}
|
||||
|
||||
{# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
|
||||
<div id="reportNatal"></div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Strefa czasowa z lokalizacji (PRE-03) — „logika dwóch lokalizacji".
|
||||
|
||||
Cel po ludzku: żeby użytkownik nie musiał znać offsetu GMT ani pamiętać o czasie
|
||||
letnim. Z dokładnych współrzędnych bierzemy strefę IANA (`tzfpy`, OFFLINE — bez
|
||||
zapytań do sieci, więc działa też bez internetu), a z niej — offset DLA DATY
|
||||
URODZENIA. `zoneinfo` zna reguły historyczne i DST, więc np. Kraków 1984 to +1h
|
||||
zimą, +2h latem, a Katmandu to +5:45.
|
||||
|
||||
Sedno PRE-03: offset ustalamy RAZ i trzymamy jako stałą liczbę w formularzu —
|
||||
przeliczenie dla dokładnej lokalizacji już nim nie rusza. Dzięki temu drobna
|
||||
zmiana współrzędnych nie przerzuca strefy ani czasu letniego i nie „przeskakuje"
|
||||
Ascendenta na sąsiedni znak. „Większa miejscowość" z wymagania jest zbędna:
|
||||
strefa IANA jest i tak regionalna, więc wioska daje tę samą strefę co pobliskie
|
||||
miasto.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
try: # zależność opcjonalna — brak biblioteki
|
||||
from tzfpy import get_tz # nie może wywalić strony, tylko wyłącza podpowiedź
|
||||
except Exception: # pragma: no cover
|
||||
get_tz = None
|
||||
|
||||
|
||||
def _fmt_offset(hours: float) -> str:
|
||||
sign = "+" if hours >= 0 else "−"
|
||||
total = int(round(abs(hours) * 60))
|
||||
return f"{sign}{total // 60}:{total % 60:02d}"
|
||||
|
||||
|
||||
def resolve(lat: float, lon: float, date: str, time: str = "12:00") -> dict | None:
|
||||
"""(lat, lon, data lokalna, godzina lokalna) → {tz, offset, dst, label}.
|
||||
|
||||
Zwraca None, gdy strefy nie da się ustalić (brak biblioteki, punkt bez strefy,
|
||||
niepoprawna data) — wtedy zostaje ręczny offset, nic się nie psuje.
|
||||
`offset` jest w godzinach (np. 5.75 dla +5:45), DST-świadomy dla podanej daty.
|
||||
"""
|
||||
if get_tz is None:
|
||||
return None
|
||||
try:
|
||||
tz = get_tz(float(lon), float(lat)) # UWAGA: tzfpy przyjmuje (lon, lat)
|
||||
except Exception:
|
||||
return None
|
||||
if not tz:
|
||||
return None
|
||||
try:
|
||||
zone = ZoneInfo(tz)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
return None
|
||||
|
||||
# Wall-time w tej strefie dla podanej daty → offset i czy czas letni jest aktywny.
|
||||
try:
|
||||
local = datetime.fromisoformat(f"{date}T{time or '12:00'}")
|
||||
except ValueError:
|
||||
try:
|
||||
local = datetime.fromisoformat(date).replace(hour=12)
|
||||
except ValueError:
|
||||
return None
|
||||
local = local.replace(tzinfo=zone)
|
||||
off = local.utcoffset()
|
||||
if off is None:
|
||||
return None
|
||||
hours = round(off.total_seconds() / 3600.0, 2)
|
||||
dst = bool(local.dst())
|
||||
label = f"{tz} · {_fmt_offset(hours)}" + (" (czas letni)" if dst else "")
|
||||
return {"tz": tz, "offset": hours, "dst": dst, "label": label}
|
||||
@@ -5,5 +5,10 @@ jinja2>=3.1
|
||||
python-multipart>=0.0.20
|
||||
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
|
||||
cryptography>=44.0
|
||||
# Strefa czasowa z lokalizacji (PRE-03): współrzędne → strefa IANA (offline, lekki
|
||||
# wheel Rust), a offset/DST liczy stdlib zoneinfo. tzdata na wypadek slim-obrazu
|
||||
# bez systemowej bazy stref.
|
||||
tzfpy>=0.15
|
||||
tzdata>=2024.1
|
||||
# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10)
|
||||
openpyxl>=3.1
|
||||
|
||||
@@ -159,3 +159,36 @@ def test_ai_text_is_escaped_before_injection():
|
||||
|
||||
def test_does_nothing_on_other_tabs():
|
||||
assert "if (!document.getElementById('readiness')) return;" in JS
|
||||
|
||||
|
||||
# ─────── podsumowanie NIE gubi opcji policzonych przy horoskopie ──────────
|
||||
# Regresja 2026-07-28: „Skompiluj" przeliczało horoskop bez stacji/tabel/
|
||||
# porównania domów, więc podsumowanie miało braki względem tego, co policzono.
|
||||
|
||||
def test_compile_form_carries_the_options():
|
||||
"""Te same opcje co „Horoskop" (synchronizowane) — inaczej nie da się ich przekazać."""
|
||||
for f in ('name="stations"', 'name="tables"', 'name="house_systems"'):
|
||||
assert f in TPL, f"brak opcji w formularzu podsumowania: {f}"
|
||||
|
||||
|
||||
def test_compile_handler_recomputes_with_all_options():
|
||||
assert "house_systems: list[str] = Form([])" in MAIN
|
||||
assert "stations: bool = Form(False)" in MAIN
|
||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
||||
assert "stations=stations, zodiac=zodiac, tables=tables" in MAIN
|
||||
|
||||
|
||||
def test_compile_pdf_recomputes_with_all_options():
|
||||
assert 'stations=bool(data.get("stations"))' in MAIN
|
||||
assert 'house_systems=[s for s in (data.get("house_systems")' in MAIN
|
||||
|
||||
|
||||
def test_compile_pdf_payload_includes_options():
|
||||
assert "house_systems: multi('house_systems')" in JS
|
||||
assert "stations: checked('stations')" in JS
|
||||
|
||||
|
||||
def test_summary_uses_shared_result_tables():
|
||||
"""Podsumowanie pokazuje te same tabele co horoskop — jeden wspólny plik,
|
||||
żeby nie mogły znowu się rozjechać."""
|
||||
assert '{% include "_result_tables.html" %}' in TPL
|
||||
|
||||
@@ -20,7 +20,15 @@ WITH_FORM = ("chart.html", "interpret.html", "timeline.html")
|
||||
# Pola, które mają wędrować między zakładkami. Gdy dojdzie nowe wspólne pole,
|
||||
# ten wykaz ma się nie rozjechać z formsync.js.
|
||||
SHARED = ("person", "date", "time", "tz_offset", "lat", "lon",
|
||||
"house_system", "zodiac", "place")
|
||||
"house_system", "zodiac", "place",
|
||||
"stations", "tables", "house_systems") # opcje też wędrują (regresja: podsumowanie liczyło się bez nich)
|
||||
|
||||
|
||||
def test_option_checkboxes_are_synced_by_type():
|
||||
"""Stacje/tabele to checkboxy (.checked), a porównanie domów to wiele checkboxów
|
||||
tej samej nazwy (lista) — formsync musi umieć oba, nie tylko .value."""
|
||||
assert "'check'" in SYNC_JS and "'multi'" in SYNC_JS
|
||||
assert "el.checked" in SYNC_JS
|
||||
|
||||
|
||||
# ─────────────────────────────── pole imienia ────────────────────────────
|
||||
@@ -90,5 +98,5 @@ def test_map_changes_are_reported_to_sync():
|
||||
|
||||
def test_sync_does_not_wipe_fields_missing_on_a_tab():
|
||||
"""Zakładka bez danego pola (np. Sygnifikatory) nie może wyczyścić wartości
|
||||
zapamiętanej z innej zakładki."""
|
||||
assert "if (!el) return;" in SYNC_JS
|
||||
zapamiętanej z innej zakładki. `collect` pomija pole, którego nie ma na stronie."""
|
||||
assert "if (!els.length) return;" in SYNC_JS
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Porównanie systemów domów naraz (PRE-05).
|
||||
|
||||
JS-a tu nie ma — sprawdzamy strukturalnie: formularz pozwala zaznaczyć kilka
|
||||
systemów, klient przekazuje je do logiki, a szablon pokazuje kuspy obok siebie.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
|
||||
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_form_lets_you_pick_extra_systems():
|
||||
"""Checkboxy dla trzech systemów — wielokrotny wybór (name powtórzony)."""
|
||||
for val in ("whole_sign", "equal", "porphyry"):
|
||||
assert f'name="house_systems" value="{val}"' in CHART, f"brak checkboxa {val}"
|
||||
|
||||
|
||||
def test_handler_accepts_and_passes_house_systems():
|
||||
assert "house_systems: list[str] = Form([])" in MAIN
|
||||
assert "house_systems=house_systems" in MAIN # przekazane do logiki
|
||||
|
||||
|
||||
def test_client_forwards_house_systems_to_logic():
|
||||
assert '"house_systems": house_systems' in CLIENT
|
||||
|
||||
|
||||
def test_comparison_table_renders_only_for_multiple_systems():
|
||||
"""Bez dodatkowych systemów nie pokazujemy pustej tabeli porównania."""
|
||||
assert "result.house_systems | length > 1" in CHART
|
||||
assert "Porównanie systemów domów" in CHART
|
||||
|
||||
|
||||
def test_comparison_table_shows_cusp_per_system():
|
||||
"""Tabela iteruje po systemach i po 12 domach — kusp w każdej komórce."""
|
||||
assert "for hs in result.house_systems" in CHART
|
||||
assert "hs.cusps[i].in_sign" in CHART
|
||||
|
||||
|
||||
def test_checkbox_state_survives_submit():
|
||||
"""Zaznaczone systemy zostają zaznaczone po przeliczeniu."""
|
||||
assert "in chosen" in CHART and "form.house_systems" in CHART
|
||||
@@ -86,3 +86,39 @@ def test_now_button_still_syncs_map():
|
||||
"""Odświeżenie nazwy nie może zastąpić przesunięcia pineski i widoku mapy."""
|
||||
body = _handler_body()
|
||||
assert "marker.setLatLng" in body and "map.setView" in body
|
||||
|
||||
|
||||
# ---- strefa czasowa z lokalizacji (PRE-03) ----
|
||||
|
||||
def test_setcoords_resolves_timezone():
|
||||
"""Nowe współrzędne (mapa/wyszukiwarka) mają odświeżyć strefę i offset."""
|
||||
src = _geo_source()
|
||||
body = src[src.index("function setCoords"):src.index("\n }", src.index("function setCoords"))]
|
||||
assert "resolveTz()" in body
|
||||
|
||||
|
||||
def test_now_button_resolves_timezone():
|
||||
"""„Tu i teraz" to nowe miejsce → nowa strefa."""
|
||||
assert "resolveTz()" in _handler_body()
|
||||
|
||||
|
||||
def test_date_change_recomputes_timezone():
|
||||
"""Czas letni zależy od daty — zmiana daty musi przeliczyć offset."""
|
||||
src = _geo_source()
|
||||
assert "dateEl.addEventListener('change', resolveTz)" in src
|
||||
|
||||
|
||||
def test_timezone_hint_does_not_clobber_manual_offset_on_load():
|
||||
"""Na wejściu podpowiadamy strefę TYLKO gdy offset wygląda na nieustawiony —
|
||||
inaczej nadpisalibyśmy wartość ręcznie wpisaną i wysłaną przez użytkownika."""
|
||||
src = _geo_source()
|
||||
assert "parseFloat(tzEl.value) === 0" in src
|
||||
|
||||
|
||||
def test_timezone_fills_offset_field_and_shows_zone():
|
||||
src = _geo_source()
|
||||
start = src.index("function resolveTz")
|
||||
body = src[start:src.index("\n }", start)]
|
||||
assert "/timezone?lat=" in body
|
||||
assert "setVal(tzEl, d.offset)" in body # wypełnia pole offsetu
|
||||
assert "d.label" in body # pokazuje wykrytą strefę
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Strefa czasowa z lokalizacji (PRE-03).
|
||||
|
||||
Najważniejsze: offset ma być DST-świadomy i liczony DLA DATY URODZENIA (reguły
|
||||
historyczne), a nie „na sztywno" — inaczej wróciłby dokładnie ten błąd, który
|
||||
PRE-03 ma usunąć (pomylona godzina / czas letni → przeskok Ascendenta).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from app import timezone as tz
|
||||
|
||||
|
||||
requires_tzfpy = pytest.mark.skipif(tz.get_tz is None, reason="brak tzfpy w środowisku")
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_krakow_summer_is_plus_two_with_dst():
|
||||
r = tz.resolve(50.0647, 19.945, "1984-06-01", "11:20")
|
||||
assert r["tz"] == "Europe/Warsaw"
|
||||
assert r["offset"] == 2.0
|
||||
assert r["dst"] is True
|
||||
assert "czas letni" in r["label"]
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_krakow_winter_is_plus_one_without_dst():
|
||||
r = tz.resolve(50.0647, 19.945, "1984-01-15", "10:00")
|
||||
assert r["offset"] == 1.0
|
||||
assert r["dst"] is False
|
||||
assert "czas letni" not in r["label"]
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_kathmandu_is_five_forty_five():
|
||||
"""Katmandu +5:45 — sztandarowy przypadek offsetu w 15-minutowym kroku."""
|
||||
r = tz.resolve(27.7172, 85.3240, "2000-05-01", "08:00")
|
||||
assert r["tz"] == "Asia/Kathmandu"
|
||||
assert r["offset"] == 5.75
|
||||
assert "+5:45" in r["label"]
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_new_york_dst_differs_by_season():
|
||||
summer = tz.resolve(40.7128, -74.0060, "2020-07-01", "12:00")
|
||||
winter = tz.resolve(40.7128, -74.0060, "2020-01-01", "12:00")
|
||||
assert summer["offset"] == -4.0 and summer["dst"] is True # EDT
|
||||
assert winter["offset"] == -5.0 and winter["dst"] is False # EST
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_missing_time_defaults_to_noon_not_crash():
|
||||
r = tz.resolve(50.0647, 19.945, "1984-06-01", "")
|
||||
assert r and r["offset"] == 2.0
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_bad_date_returns_none_not_exception():
|
||||
assert tz.resolve(50.0, 19.9, "nonsense", "12:00") is None
|
||||
|
||||
|
||||
def test_no_library_degrades_to_none(monkeypatch):
|
||||
"""Brak tzfpy nie może wywalić — po prostu bez podpowiedzi (ręczny offset zostaje)."""
|
||||
monkeypatch.setattr(tz, "get_tz", None)
|
||||
assert tz.resolve(50.0, 19.9, "1984-06-01", "11:20") is None
|
||||
|
||||
|
||||
# ---- endpoint /timezone ----
|
||||
|
||||
def _client():
|
||||
import os
|
||||
|
||||
os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@requires_tzfpy
|
||||
def test_endpoint_returns_offset_for_a_point():
|
||||
r = _client().get("/timezone", params={"lat": 50.0647, "lon": 19.945,
|
||||
"date": "1984-06-01", "time": "11:20"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["offset"] == 2.0 and body["tz"] == "Europe/Warsaw"
|
||||
|
||||
|
||||
def test_endpoint_404_when_zone_cannot_be_resolved(monkeypatch):
|
||||
"""Gdy strefy nie da się ustalić → 404, front zostawia ręczny offset."""
|
||||
from app import timezone as tzmod
|
||||
monkeypatch.setattr(tzmod, "get_tz", None)
|
||||
r = _client().get("/timezone", params={"lat": 0.0, "lon": 0.0, "date": "2000-01-01"})
|
||||
assert r.status_code == 404
|
||||
Reference in New Issue
Block a user