d4a37aca52
QoL 2/2: pole „Szukaj miejsca" (nazwa/adres/POI) + interaktywna mapa na formularzach horoskopu / interpretacji / kalendarza. Bez klucza API. - geocode.py: proxy OSM/Nominatim PO STRONIE SERWERA (poprawny User-Agent, throttling ~1 req/s, cache TTL 1h) — endpointy /geocode i /reverse. Wolanie z serwera, nie z przegladarki: latwiej trzymac polityke Nominatim i dziala niezaleznie od secure-context (http://<ip>). - _location_picker.html: wspolny partial (search + wyniki + mapa + atrybucja), wpiety includem do 3 formularzy z polami lat/lon. - geo.js: Leaflet — wyszukiwanie (debounce), klik na wynik ustawia lat/lon i centruje mape, klik/drag pineski ustawia wspolrzedne + /reverse pokazuje nazwe; sync z „Tu i teraz" (now.js emituje event astrololo:coords). - Leaflet 1.9.4 vendorowany lokalnie (static/vendor/leaflet, BSD-2-Clause, permisywny) — niezaleznosc od CDN; kafelki mapy z OSM. Marker jako divIcon (bez plikow PNG). - styles.css: style pod ciemny motyw. Zweryfikowane w przegladarce: domyslny widok (pineska na Szpitalu Barlickiego), wyszukanie „Wawel Krakow" -> lista -> klik ustawia 50.0547/19.9361 i przesuwa mape, klik w mape ustawia wspolrzedne + reverse wypelnia nazwe. Zero bledow w konsoli. /geocode i /reverse zwracaja szpital; cache dziala. Uwaga wdrozeniowa: pod prezentacji potrzebuje egressu do nominatim.openstreetmap.org; przegladarki — do tile.openstreetmap.org. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
96 lines
3.3 KiB
Python
96 lines
3.3 KiB
Python
"""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
|