Files
astrololo/services/presentation/app/static/geo.js
T
gitea 4bdfb673cc
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 11m2s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m56s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 34s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 23s
build / build (push) Successful in 1m40s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m52s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 32s
Testy / Kontrola składni wszystkich warstw (push) Successful in 19s
feat(prezentacja): strefa czasowa z lokalizacji — DST-świadomy offset (PRE-03)
„Logika dwóch lokalizacji": dotąd offset GMT był ręcznym polem (PRE-19) — trzeba
było go znać i samemu pamiętać o czasie letnim. Teraz liczymy go z lokalizacji.

Sedno wymagania: strefę ustalamy RAZ i trzymamy jako stałą liczbę, żeby drobna
zmiana współrzędnych nie przerzuciła DST i nie „przeskoczyła" Ascendenta na
sąsiedni znak. „Większa miejscowość z bazy" okazuje się zbędna — strefa IANA jest
i tak regionalna, więc wioska daje tę samą strefę co pobliskie miasto.

Jak:
- `timezone.py`: współrzędne → strefa IANA (tzfpy, OFFLINE — bez sieci), a z niej
  offset DLA DATY URODZENIA. `zoneinfo`/`tzdata` znają reguły historyczne i DST:
  Kraków 1984 to +1h zimą, +2h latem; Katmandu +5:45. Degraduje się do None (brak
  biblioteki / punkt bez strefy / zła data) — wtedy zostaje ręczny offset.
- Endpoint `GET /timezone?lat&lon&date&time` → {tz, offset, dst, label}. 404, gdy
  nie da się ustalić.
- `geo.js`: po wyborze miejsca (mapa / wyszukiwarka / „Tu i teraz") oraz przy
  zmianie DATY (bo DST zależy od pory roku) pobiera offset i wypełnia pole
  tz_offset, pokazując wykrytą strefę („Wykryto: Europe/Warsaw · +2:00 (czas
  letni)"). Pole zostaje edytowalne. Na wejściu podpowiada tylko gdy offset
  wygląda na nieustawiony — nie nadpisuje wartości ręcznie wpisanej i wysłanej.

Zależności (lekkie, offline): tzfpy (wheel Rust) + tzdata (dla zoneinfo w slim-obrazie).

Weryfikacja: żywy serwer — Kraków 1984-06 → +2:00 (czas letni), 1984-01 → +1:00,
Katmandu → +5:45. Testy: +12 strefa (moduł + endpoint), +5 JS. Prezentacja 187.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 22:42:31 +02:00

183 lines
8.3 KiB
JavaScript

// 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');
// ---- 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); };
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);
// Ustawienie `.value` z kodu NIE wywołuje zdarzeń, więc synchronizacja
// między zakładkami (formsync.js) przegapiłaby zmiany z mapy. Zgłaszamy je
// jawnie przez `change`.
function setVal(el, value) {
if (!el) return;
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function setCoords(lat, lon, fromMap) {
setVal(latEl, lat.toFixed(4));
setVal(lonEl, lon.toFixed(4));
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) {
// Czyścimy pole OD RAZU. Dopóki nie znamy nazwy nowego punktu, stara jest
// po prostu nieprawdziwa — pokazywałaby inne miejsce niż współrzędne obok.
// Dotyczy też sytuacji, gdy /reverse nie odpowie (brak sieci): lepiej puste
// pole i poprawne współrzędne niż nazwa, która kłamie.
if (searchEl) setVal(searchEl, '');
fetch('/reverse?lat=' + lat + '&lon=' + lon)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (d) { if (d && d.name && searchEl) setVal(searchEl, 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);
setVal(searchEl, 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" ----
// Poza przesunięciem pineski MUSIMY odświeżyć pole tekstowe: po „Tu i teraz"
// zostawała w nim stara, ręcznie wpisana nazwa, więc formularz pokazywał jedno
// miejsce, a współrzędne wskazywały już inne. reverseName pyta o nazwę punktu,
// na który faktycznie ustawiła się mapa.
document.addEventListener('astrololo:coords', function () {
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();
});
})();