feat(tradein): MapPicker snap marker — Phase 5 of #582 #594
5 changed files with 677 additions and 37 deletions
|
|
@ -6,7 +6,7 @@ import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.core.db import get_db
|
from app.core.db import get_db
|
||||||
|
|
@ -71,17 +71,49 @@ async def suggest_addresses(
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/reverse")
|
class ReverseResponse(BaseModel):
|
||||||
|
"""Reverse-geocode response для MapPicker'а.
|
||||||
|
|
||||||
|
`lat`/`lon` — echo входной точки клика (для логики «отодвинули ли далеко»).
|
||||||
|
`snapped_lat`/`snapped_lon` — центр matched здания от provider'а. Если
|
||||||
|
`precision in ("exact","number")` фронт двигает marker на snapped point —
|
||||||
|
пользователь видит «магнит к дому». Для остальных precision snapped == input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
address: str
|
||||||
|
lat: float = Field(..., description="Исходная latitude клика")
|
||||||
|
lon: float = Field(..., description="Исходная longitude клика")
|
||||||
|
snapped_lat: float = Field(..., description="Latitude центра matched здания")
|
||||||
|
snapped_lon: float = Field(..., description="Longitude центра matched здания")
|
||||||
|
precision: str = Field(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
"Yandex-style: exact/number/street/range/near/locality/other/cadastral. "
|
||||||
|
"Фронт двигает marker только если exact/number/cadastral."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
provider: str = Field(..., description="cadastral | yandex | nominatim")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reverse", response_model=ReverseResponse)
|
||||||
async def reverse(
|
async def reverse(
|
||||||
lat: Annotated[float, Query(ge=-90, le=90)],
|
lat: Annotated[float, Query(ge=-90, le=90)],
|
||||||
lon: Annotated[float, Query(ge=-180, le=180)],
|
lon: Annotated[float, Query(ge=-180, le=180)],
|
||||||
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
db: Annotated[Session, Depends(get_db)] = None, # type: ignore[assignment]
|
||||||
) -> dict[str, object]:
|
) -> ReverseResponse:
|
||||||
"""Обратный геокодинг — координаты с карты → адрес (map-picker).
|
"""Обратный геокодинг — координаты с карты → адрес + snapped точка здания.
|
||||||
|
|
||||||
Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
|
Пример: /api/v1/geocode/reverse?lat=56.8389&lon=60.6057
|
||||||
"""
|
"""
|
||||||
address = await reverse_geocode(lat, lon, db=db)
|
result = await reverse_geocode(lat, lon, db=db)
|
||||||
if address is None:
|
if result is None:
|
||||||
raise HTTPException(status_code=404, detail="address not found for coordinates")
|
raise HTTPException(status_code=404, detail="address not found for coordinates")
|
||||||
return {"address": address, "lat": lat, "lon": lon}
|
return ReverseResponse(
|
||||||
|
address=result.address,
|
||||||
|
lat=lat,
|
||||||
|
lon=lon,
|
||||||
|
snapped_lat=result.snapped_lat,
|
||||||
|
snapped_lon=result.snapped_lon,
|
||||||
|
precision=result.precision,
|
||||||
|
provider=result.provider,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -649,6 +649,40 @@ async def geocode(address: str, db: Session) -> GeocodeResult | None:
|
||||||
|
|
||||||
|
|
||||||
# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
|
# ── Reverse: координаты → адрес (для map-picker'а) ──────────────────────────
|
||||||
|
# Precision levels which we treat as "снап к зданию имеет смысл":
|
||||||
|
# - exact — точный матч на здание (Yandex)
|
||||||
|
# - number — найден дом с номером (то что нам надо для квартирного оценщика)
|
||||||
|
# - cadastral — Cadastral FDW row (то же по точности что Yandex "number")
|
||||||
|
# Остальные (street/range/near/locality/other) → не снапаем, marker остаётся на клике.
|
||||||
|
_SNAP_PRECISIONS = {"exact", "number", "cadastral"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReverseGeocodeResult:
|
||||||
|
"""Reverse-геокодинг с snapped координатами matched здания.
|
||||||
|
|
||||||
|
- `address` — текстовый адрес (улица + дом + город).
|
||||||
|
- `snapped_lat` — координата центра здания если provider дал её,
|
||||||
|
иначе echo `lat` входной точки (для precision=street/locality).
|
||||||
|
- `snapped_lon` — то же.
|
||||||
|
- `precision` — yandex-style: `exact`/`number`/`street`/`range`/`near`/
|
||||||
|
`locality`/`other`/`cadastral`. Используется фронтом чтобы
|
||||||
|
решить — двигать marker (exact/number) или нет.
|
||||||
|
- `provider` — кто дал результат (`yandex`/`nominatim`/`cadastral`).
|
||||||
|
|
||||||
|
Фронт MapPicker'а после клика смотрит на precision: если `exact`/`number`
|
||||||
|
и snapped >5m от click point — пересаживает marker на snapped point
|
||||||
|
(чтобы пользователь видел центр дома по Яндексу, а не свой клик во дворе).
|
||||||
|
Для остальных precision marker остаётся где кликнули — не врём что нашли
|
||||||
|
точное здание.
|
||||||
|
"""
|
||||||
|
address: str
|
||||||
|
snapped_lat: float
|
||||||
|
snapped_lon: float
|
||||||
|
precision: str
|
||||||
|
provider: Literal["yandex", "nominatim", "cadastral"]
|
||||||
|
|
||||||
|
|
||||||
def _format_reverse_address(addr: dict) -> str | None:
|
def _format_reverse_address(addr: dict) -> str | None:
|
||||||
"""Собирает чистый уличный адрес из Nominatim address-объекта.
|
"""Собирает чистый уличный адрес из Nominatim address-объекта.
|
||||||
|
|
||||||
|
|
@ -683,8 +717,84 @@ def _format_reverse_address(addr: dict) -> str | None:
|
||||||
|
|
||||||
|
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||||
async def _nominatim_reverse(lat: float, lon: float) -> str | None:
|
async def _yandex_reverse(
|
||||||
"""Nominatim /reverse — extracted for fallback usage. @retry handles transient errors."""
|
lat: float, lon: float, api_key: str
|
||||||
|
) -> ReverseGeocodeResult | None:
|
||||||
|
"""Yandex Geocoder /reverse — возвращает snapped Point.pos здания + precision.
|
||||||
|
|
||||||
|
Docs: https://yandex.ru/dev/maps/geocoder/doc/desc/concepts/input_params.html
|
||||||
|
Параметр `geocode` принимает `lon,lat` (важно — обратный порядок!).
|
||||||
|
"""
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://geocode-maps.yandex.ru/1.x/",
|
||||||
|
params={
|
||||||
|
"apikey": api_key,
|
||||||
|
"geocode": f"{lon},{lat}",
|
||||||
|
"format": "json",
|
||||||
|
"results": "1",
|
||||||
|
"kind": "house", # просим именно здание (house), не улицу
|
||||||
|
"lang": "ru_RU",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
members = data.get("response", {}).get("GeoObjectCollection", {}).get("featureMember", [])
|
||||||
|
if not members:
|
||||||
|
return None
|
||||||
|
obj = members[0].get("GeoObject", {})
|
||||||
|
try:
|
||||||
|
lon_str, lat_str = obj["Point"]["pos"].split()
|
||||||
|
snapped_lat = float(lat_str)
|
||||||
|
snapped_lon = float(lon_str)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return None
|
||||||
|
meta = obj.get("metaDataProperty", {}).get("GeocoderMetaData", {})
|
||||||
|
precision = str(meta.get("precision", "other"))
|
||||||
|
address_text = str(meta.get("text") or obj.get("name") or "")
|
||||||
|
# Yandex address text начинается с «Россия, Свердловская область, …» — режем prefix,
|
||||||
|
# оставляем «улица, дом, город» для consistency с Nominatim/cadastral.
|
||||||
|
if address_text:
|
||||||
|
# «Россия, Свердловская область, Екатеринбург, улица Малышева, 51»
|
||||||
|
# → «улица Малышева, 51, Екатеринбург» (drop country/oblast, swap city/street)
|
||||||
|
parts = [p.strip() for p in address_text.split(",") if p.strip()]
|
||||||
|
filtered = [
|
||||||
|
p for p in parts
|
||||||
|
if p not in {"Россия", "Свердловская область"}
|
||||||
|
and not p.startswith("городской округ")
|
||||||
|
]
|
||||||
|
# Найдём locality (Екатеринбург / Берёзовский / …) и переставим в конец
|
||||||
|
locality = None
|
||||||
|
rest: list[str] = []
|
||||||
|
for p in filtered:
|
||||||
|
if locality is None and p in {"Екатеринбург"}:
|
||||||
|
locality = p
|
||||||
|
else:
|
||||||
|
rest.append(p)
|
||||||
|
if locality and rest:
|
||||||
|
address_text = ", ".join([*rest, locality])
|
||||||
|
else:
|
||||||
|
address_text = ", ".join(filtered)
|
||||||
|
|
||||||
|
if not address_text:
|
||||||
|
return None
|
||||||
|
return ReverseGeocodeResult(
|
||||||
|
address=address_text,
|
||||||
|
snapped_lat=snapped_lat,
|
||||||
|
snapped_lon=snapped_lon,
|
||||||
|
precision=precision,
|
||||||
|
provider="yandex",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=8))
|
||||||
|
async def _nominatim_reverse(lat: float, lon: float) -> ReverseGeocodeResult | None:
|
||||||
|
"""Nominatim /reverse → ReverseGeocodeResult с snapped coords из item.lat/lon.
|
||||||
|
|
||||||
|
Nominatim возвращает координаты центра matched feature (building/way/node).
|
||||||
|
precision выводим из наличия `house_number` в addressdetails.
|
||||||
|
"""
|
||||||
headers = {
|
headers = {
|
||||||
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
"User-Agent": f"TradeInMVP/0.1 (contact: {settings.contact_email})",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
|
|
@ -706,31 +816,121 @@ async def _nominatim_reverse(lat: float, lon: float) -> str | None:
|
||||||
if not isinstance(data, dict) or "error" in data:
|
if not isinstance(data, dict) or "error" in data:
|
||||||
return None
|
return None
|
||||||
addr = data.get("address")
|
addr = data.get("address")
|
||||||
|
address_text: str | None = None
|
||||||
if isinstance(addr, dict):
|
if isinstance(addr, dict):
|
||||||
street = _format_reverse_address(addr)
|
address_text = _format_reverse_address(addr)
|
||||||
if street:
|
if not address_text:
|
||||||
return street
|
display = data.get("display_name")
|
||||||
return data.get("display_name")
|
if not display:
|
||||||
|
return None
|
||||||
|
address_text = str(display)
|
||||||
|
# snapped coords — то что вернул Nominatim (центр matched feature)
|
||||||
|
try:
|
||||||
|
snapped_lat = float(data["lat"])
|
||||||
|
snapped_lon = float(data["lon"])
|
||||||
|
except (KeyError, ValueError, TypeError):
|
||||||
|
snapped_lat, snapped_lon = lat, lon
|
||||||
|
has_house = isinstance(addr, dict) and bool(addr.get("house_number"))
|
||||||
|
precision = "number" if has_house else "street"
|
||||||
|
return ReverseGeocodeResult(
|
||||||
|
address=address_text,
|
||||||
|
snapped_lat=snapped_lat,
|
||||||
|
snapped_lon=snapped_lon,
|
||||||
|
precision=precision,
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def reverse_geocode(lat: float, lon: float, db: Session | None = None) -> str | None:
|
def _cadastral_reverse_sync_full(
|
||||||
"""Cadastral FDW → Nominatim fallback.
|
db: Session, lat: float, lon: float, radius_m: int = 200
|
||||||
|
) -> tuple[str, float, float] | None:
|
||||||
|
"""Полный вариант cadastral reverse — возвращает (address, snapped_lat, snapped_lon).
|
||||||
|
|
||||||
|
Отдельная функция чтобы старый `_cadastral_reverse_sync` (только адрес)
|
||||||
|
остался backward-compatible — его держит `tests/services/test_cadastral_reverse.py`.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
text("""
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT cad_num, readable_address, lat, lon,
|
||||||
|
111320.0 * sqrt(
|
||||||
|
pow(CAST(:lat AS double precision) - lat, 2) +
|
||||||
|
pow(
|
||||||
|
cos(radians(CAST(:lat AS double precision)))
|
||||||
|
* (CAST(:lon AS double precision) - lon), 2
|
||||||
|
)
|
||||||
|
) AS dist_m
|
||||||
|
FROM gendesign_cad_buildings
|
||||||
|
WHERE lat BETWEEN CAST(:lat AS double precision) - 0.0025
|
||||||
|
AND CAST(:lat AS double precision) + 0.0025
|
||||||
|
AND lon BETWEEN CAST(:lon AS double precision) - 0.005
|
||||||
|
AND CAST(:lon AS double precision) + 0.005
|
||||||
|
AND readable_address !~* '(гараж|снт|садовод|товарищ|уч\\.)'
|
||||||
|
)
|
||||||
|
SELECT readable_address, lat, lon, dist_m
|
||||||
|
FROM candidates
|
||||||
|
WHERE dist_m < CAST(:radius AS double precision)
|
||||||
|
ORDER BY dist_m ASC
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"lat": lat, "lon": lon, "radius": float(radius_m)},
|
||||||
|
).first()
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"cadastral reverse full failed for (%.5f, %.5f)", lat, lon, exc_info=True
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return (str(row.readable_address), float(row.lat), float(row.lon))
|
||||||
|
|
||||||
|
|
||||||
|
async def reverse_geocode(
|
||||||
|
lat: float, lon: float, db: Session | None = None
|
||||||
|
) -> ReverseGeocodeResult | None:
|
||||||
|
"""Cadastral FDW → Yandex (если key) → Nominatim. Возвращает snapped coords.
|
||||||
|
|
||||||
Возвращает None если ни один источник не дал адрес. Endpoint
|
Возвращает None если ни один источник не дал адрес. Endpoint
|
||||||
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
|
api/v1/geocode/reverse сам выкинет 404. НЕ даёт выйти HTTPStatusError
|
||||||
наверх — раньше Nominatim 403 → RetryError → FastAPI 500.
|
наверх — раньше Nominatim 403 → RetryError → FastAPI 500.
|
||||||
|
|
||||||
|
Snapped lat/lon — это центр matched здания (от provider'а), не echo
|
||||||
|
входных координат. Фронт по precision решает — двигать marker (exact/number)
|
||||||
|
или оставить на клике (street/locality).
|
||||||
|
|
||||||
db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier).
|
db: если передан — cadastral lookup через gendesign_cad_buildings FDW (первый tier).
|
||||||
"""
|
"""
|
||||||
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
|
# 1. Cadastral FDW primary (без внешнего API, возвращает жилой дом not POI)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
cadastral = await asyncio.to_thread(_cadastral_reverse_sync, db, lat, lon)
|
cad = await asyncio.to_thread(_cadastral_reverse_sync_full, db, lat, lon)
|
||||||
if cadastral:
|
if cad is not None:
|
||||||
return cadastral
|
address, snap_lat, snap_lon = cad
|
||||||
|
return ReverseGeocodeResult(
|
||||||
|
address=address,
|
||||||
|
snapped_lat=snap_lat,
|
||||||
|
snapped_lon=snap_lon,
|
||||||
|
precision="number", # cadastral row = здание с house number
|
||||||
|
provider="cadastral",
|
||||||
|
)
|
||||||
|
|
||||||
# 2. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
|
# 2. Yandex — основной источник snap'а (его Point.pos = центр здания)
|
||||||
|
if settings.yandex_geocoder_api_key:
|
||||||
|
try:
|
||||||
|
result = await _yandex_reverse(lat, lon, settings.yandex_geocoder_api_key)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
|
logger.exception("yandex reverse failed for (%.5f, %.5f)", lat, lon)
|
||||||
|
|
||||||
|
# 3. Nominatim fallback (wrap to prevent 500 on ban/rate-limit)
|
||||||
try:
|
try:
|
||||||
return await _nominatim_reverse(lat, lon)
|
return await _nominatim_reverse(lat, lon)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon)
|
logger.exception("nominatim reverse failed for (%.5f, %.5f)", lat, lon)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def snap_precision_useful(precision: str) -> bool:
|
||||||
|
"""True если precision означает «нашли точное здание» — фронт двигает marker."""
|
||||||
|
return precision in _SNAP_PRECISIONS
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
from app.services.geocoder import ( # noqa: E402
|
from app.services.geocoder import ( # noqa: E402
|
||||||
GeocodeSuggestion,
|
GeocodeSuggestion,
|
||||||
|
ReverseGeocodeResult,
|
||||||
_cadastral_forward_sync,
|
_cadastral_forward_sync,
|
||||||
_cadastral_reverse_sync,
|
_cadastral_reverse_sync,
|
||||||
geocode,
|
geocode,
|
||||||
|
|
@ -157,54 +158,121 @@ def test_reverse_sync_returns_none_on_db_error() -> None:
|
||||||
|
|
||||||
# ── reverse_geocode ───────────────────────────────────────────────────────────
|
# ── reverse_geocode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _nom_result(address: str) -> ReverseGeocodeResult:
|
||||||
|
return ReverseGeocodeResult(
|
||||||
|
address=address,
|
||||||
|
snapped_lat=56.838,
|
||||||
|
snapped_lon=60.605,
|
||||||
|
precision="number",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
|
async def test_reverse_geocode_prefers_cadastral_over_nominatim() -> None:
|
||||||
"""Cadastral returns address → Nominatim is never called."""
|
"""Cadastral returns address → Yandex/Nominatim never called."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"app.services.geocoder._cadastral_reverse_sync",
|
"app.services.geocoder._cadastral_reverse_sync_full",
|
||||||
return_value="ул. Малышева, 30, Екатеринбург",
|
return_value=("ул. Малышева, 30, Екатеринбург", 56.8385, 60.6052),
|
||||||
) as mock_cad,
|
) as mock_cad,
|
||||||
|
patch("app.services.geocoder.settings") as mock_settings,
|
||||||
|
patch(
|
||||||
|
"app.services.geocoder._yandex_reverse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_yandex,
|
||||||
patch(
|
patch(
|
||||||
"app.services.geocoder._nominatim_reverse",
|
"app.services.geocoder._nominatim_reverse",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
) as mock_nom,
|
) as mock_nom,
|
||||||
):
|
):
|
||||||
|
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||||
result = await reverse_geocode(56.838, 60.605, db=db)
|
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||||
|
|
||||||
assert result == "ул. Малышева, 30, Екатеринбург"
|
assert result is not None
|
||||||
|
assert result.address == "ул. Малышева, 30, Екатеринбург"
|
||||||
|
assert result.snapped_lat == 56.8385
|
||||||
|
assert result.snapped_lon == 60.6052
|
||||||
|
assert result.precision == "number"
|
||||||
|
assert result.provider == "cadastral"
|
||||||
mock_cad.assert_called_once_with(db, 56.838, 60.605)
|
mock_cad.assert_called_once_with(db, 56.838, 60.605)
|
||||||
|
mock_yandex.assert_not_called()
|
||||||
|
mock_nom.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reverse_geocode_uses_yandex_when_cadastral_empty() -> None:
|
||||||
|
"""Cadastral None + Yandex key set → Yandex called, Nominatim skipped."""
|
||||||
|
db = MagicMock()
|
||||||
|
yandex_result = ReverseGeocodeResult(
|
||||||
|
address="улица Малышева, 51, Екатеринбург",
|
||||||
|
snapped_lat=56.838004,
|
||||||
|
snapped_lon=60.586155,
|
||||||
|
precision="exact",
|
||||||
|
provider="yandex",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||||
|
patch("app.services.geocoder.settings") as mock_settings,
|
||||||
|
patch(
|
||||||
|
"app.services.geocoder._yandex_reverse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=yandex_result,
|
||||||
|
) as mock_yandex,
|
||||||
|
patch(
|
||||||
|
"app.services.geocoder._nominatim_reverse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_nom,
|
||||||
|
):
|
||||||
|
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||||
|
result = await reverse_geocode(56.838, 60.586, db=db)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.provider == "yandex"
|
||||||
|
assert result.precision == "exact"
|
||||||
|
assert result.snapped_lat == 56.838004
|
||||||
|
mock_yandex.assert_called_once()
|
||||||
mock_nom.assert_not_called()
|
mock_nom.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() -> None:
|
async def test_reverse_geocode_falls_back_to_nominatim_when_cadastral_none() -> None:
|
||||||
"""Cadastral returns None → Nominatim called and result returned."""
|
"""Cadastral None + no Yandex key → Nominatim called."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch("app.services.geocoder._cadastral_reverse_sync", return_value=None),
|
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||||
|
patch("app.services.geocoder.settings") as mock_settings,
|
||||||
patch(
|
patch(
|
||||||
"app.services.geocoder._nominatim_reverse",
|
"app.services.geocoder._nominatim_reverse",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value="Трамвайный переулок, 2, Екатеринбург",
|
return_value=_nom_result("Трамвайный переулок, 2, Екатеринбург"),
|
||||||
) as mock_nom,
|
) as mock_nom,
|
||||||
):
|
):
|
||||||
|
mock_settings.yandex_geocoder_api_key = None
|
||||||
result = await reverse_geocode(56.838, 60.605, db=db)
|
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||||
|
|
||||||
assert result == "Трамвайный переулок, 2, Екатеринбург"
|
assert result is not None
|
||||||
|
assert result.address == "Трамвайный переулок, 2, Екатеринбург"
|
||||||
|
assert result.provider == "nominatim"
|
||||||
mock_nom.assert_called_once_with(56.838, 60.605)
|
mock_nom.assert_called_once_with(56.838, 60.605)
|
||||||
|
|
||||||
|
|
||||||
async def test_reverse_geocode_returns_none_when_both_fail() -> None:
|
async def test_reverse_geocode_returns_none_when_all_providers_fail() -> None:
|
||||||
"""Both cadastral and Nominatim fail → returns None, no exception raised (500 fix)."""
|
"""Cadastral + Yandex + Nominatim — все падают → returns None, no exception."""
|
||||||
db = MagicMock()
|
db = MagicMock()
|
||||||
with (
|
with (
|
||||||
patch("app.services.geocoder._cadastral_reverse_sync", return_value=None),
|
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||||
|
patch("app.services.geocoder.settings") as mock_settings,
|
||||||
|
patch(
|
||||||
|
"app.services.geocoder._yandex_reverse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("Yandex 429 rate-limited"),
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"app.services.geocoder._nominatim_reverse",
|
"app.services.geocoder._nominatim_reverse",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
|
side_effect=RuntimeError("Nominatim 403 Forbidden — IP banned"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||||
result = await reverse_geocode(56.838, 60.605, db=db)
|
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||||
|
|
||||||
# Must return None, not raise
|
# Must return None, not raise
|
||||||
|
|
@ -212,21 +280,49 @@ async def test_reverse_geocode_returns_none_when_both_fail() -> None:
|
||||||
|
|
||||||
|
|
||||||
async def test_reverse_geocode_without_db_skips_cadastral() -> None:
|
async def test_reverse_geocode_without_db_skips_cadastral() -> None:
|
||||||
"""When db=None, cadastral tier is skipped entirely → goes straight to Nominatim."""
|
"""When db=None, cadastral tier is skipped entirely → goes straight to Yandex/Nominatim."""
|
||||||
with (
|
with (
|
||||||
patch(
|
patch(
|
||||||
"app.services.geocoder._cadastral_reverse_sync",
|
"app.services.geocoder._cadastral_reverse_sync_full",
|
||||||
) as mock_cad,
|
) as mock_cad,
|
||||||
|
patch("app.services.geocoder.settings") as mock_settings,
|
||||||
patch(
|
patch(
|
||||||
"app.services.geocoder._nominatim_reverse",
|
"app.services.geocoder._nominatim_reverse",
|
||||||
new_callable=AsyncMock,
|
new_callable=AsyncMock,
|
||||||
return_value="ул. Ленина, 1, Екатеринбург",
|
return_value=_nom_result("ул. Ленина, 1, Екатеринбург"),
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
|
mock_settings.yandex_geocoder_api_key = None
|
||||||
result = await reverse_geocode(56.838, 60.605, db=None)
|
result = await reverse_geocode(56.838, 60.605, db=None)
|
||||||
|
|
||||||
mock_cad.assert_not_called()
|
mock_cad.assert_not_called()
|
||||||
assert result == "ул. Ленина, 1, Екатеринбург"
|
assert result is not None
|
||||||
|
assert result.address == "ул. Ленина, 1, Екатеринбург"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reverse_geocode_falls_through_yandex_to_nominatim_on_exception() -> None:
|
||||||
|
"""Yandex raises → Nominatim still tried."""
|
||||||
|
db = MagicMock()
|
||||||
|
with (
|
||||||
|
patch("app.services.geocoder._cadastral_reverse_sync_full", return_value=None),
|
||||||
|
patch("app.services.geocoder.settings") as mock_settings,
|
||||||
|
patch(
|
||||||
|
"app.services.geocoder._yandex_reverse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("Yandex 500"),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"app.services.geocoder._nominatim_reverse",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_nom_result("ул. Дублёр, 1"),
|
||||||
|
) as mock_nom,
|
||||||
|
):
|
||||||
|
mock_settings.yandex_geocoder_api_key = "fake-key"
|
||||||
|
result = await reverse_geocode(56.838, 60.605, db=db)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.provider == "nominatim"
|
||||||
|
mock_nom.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
# ── geocode: cadastral as first tier ─────────────────────────────────────────
|
# ── geocode: cadastral as first tier ─────────────────────────────────────────
|
||||||
|
|
|
||||||
228
tradein-mvp/backend/tests/test_geocode_reverse_api.py
Normal file
228
tradein-mvp/backend/tests/test_geocode_reverse_api.py
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
"""Tests for /api/v1/geocode/reverse — snapped coords + precision in response.
|
||||||
|
|
||||||
|
Bug context (issue #582 Phase 5):
|
||||||
|
До этого PR'а endpoint echo'ил input lat/lon в ответе. После reverse-геокодинга
|
||||||
|
адрес resolved'ился до canonical здания (ул. Малышева, 125), но marker на фронте
|
||||||
|
оставался где user кликнул — иногда в проезде / дворе.
|
||||||
|
|
||||||
|
Fix: backend теперь возвращает snapped_lat/snapped_lon (центр matched здания
|
||||||
|
от Yandex/Nominatim/cadastral) + precision, фронт двигает marker если precision
|
||||||
|
in (exact, number, cadastral).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
# DATABASE_URL required by config before any app import.
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
# WeasyPrint stub — not installed in CI without GTK.
|
||||||
|
_wp_mock = MagicMock()
|
||||||
|
sys.modules.setdefault("weasyprint", _wp_mock)
|
||||||
|
|
||||||
|
|
||||||
|
import pytest # noqa: E402
|
||||||
|
from fastapi import FastAPI # noqa: E402
|
||||||
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
|
|
||||||
|
from app.api.v1 import geocode as geocode_module # noqa: E402
|
||||||
|
from app.core.db import get_db # noqa: E402
|
||||||
|
from app.services.geocoder import ( # noqa: E402
|
||||||
|
ReverseGeocodeResult,
|
||||||
|
snap_precision_useful,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app() -> FastAPI:
|
||||||
|
application = FastAPI()
|
||||||
|
application.include_router(geocode_module.router, prefix="/api/v1/geocode")
|
||||||
|
application.dependency_overrides[get_db] = lambda: MagicMock()
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoint response shape ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_reverse_endpoint_returns_snapped_fields(app: FastAPI) -> None:
|
||||||
|
"""Endpoint should include address + lat/lon (echo) + snapped_lat/snapped_lon + precision."""
|
||||||
|
client = TestClient(app)
|
||||||
|
fake = ReverseGeocodeResult(
|
||||||
|
address="улица Малышева, 51, Екатеринбург",
|
||||||
|
snapped_lat=56.838004,
|
||||||
|
snapped_lon=60.586155,
|
||||||
|
precision="exact",
|
||||||
|
provider="yandex",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.geocode.reverse_geocode",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=fake,
|
||||||
|
):
|
||||||
|
r = client.get("/api/v1/geocode/reverse?lat=56.8381&lon=60.5860")
|
||||||
|
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# Echo input
|
||||||
|
assert body["lat"] == 56.8381
|
||||||
|
assert body["lon"] == 60.5860
|
||||||
|
# Snap from provider
|
||||||
|
assert body["address"] == "улица Малышева, 51, Екатеринбург"
|
||||||
|
assert body["snapped_lat"] == 56.838004
|
||||||
|
assert body["snapped_lon"] == 60.586155
|
||||||
|
assert body["precision"] == "exact"
|
||||||
|
assert body["provider"] == "yandex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_endpoint_404_when_no_address(app: FastAPI) -> None:
|
||||||
|
"""Provider returned None → endpoint должен дать 404, не 500."""
|
||||||
|
client = TestClient(app)
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.geocode.reverse_geocode",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
r = client.get("/api/v1/geocode/reverse?lat=56.0&lon=60.0")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_reverse_endpoint_street_precision_does_not_lose_snap_fields(app: FastAPI) -> None:
|
||||||
|
"""Для precision=street snapped_lat/lon всё равно присутствуют (могут == input)."""
|
||||||
|
client = TestClient(app)
|
||||||
|
fake = ReverseGeocodeResult(
|
||||||
|
address="улица Малышева, Екатеринбург",
|
||||||
|
snapped_lat=56.84,
|
||||||
|
snapped_lon=60.61,
|
||||||
|
precision="street",
|
||||||
|
provider="nominatim",
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"app.api.v1.geocode.reverse_geocode",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=fake,
|
||||||
|
):
|
||||||
|
r = client.get("/api/v1/geocode/reverse?lat=56.84&lon=60.61")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "snapped_lat" in body
|
||||||
|
assert "snapped_lon" in body
|
||||||
|
assert body["precision"] == "street"
|
||||||
|
|
||||||
|
|
||||||
|
# ── snap_precision_useful helper ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_snap_precision_useful_exact_and_number() -> None:
|
||||||
|
assert snap_precision_useful("exact") is True
|
||||||
|
assert snap_precision_useful("number") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_snap_precision_useful_rejects_street_and_other() -> None:
|
||||||
|
assert snap_precision_useful("street") is False
|
||||||
|
assert snap_precision_useful("range") is False
|
||||||
|
assert snap_precision_useful("near") is False
|
||||||
|
assert snap_precision_useful("locality") is False
|
||||||
|
assert snap_precision_useful("other") is False
|
||||||
|
assert snap_precision_useful("") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Yandex reverse parsing ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_yandex_reverse_parses_snapped_point_and_precision() -> None:
|
||||||
|
"""`_yandex_reverse` извлекает Point.pos (lon lat) и precision из metaDataProperty."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.services.geocoder import _yandex_reverse
|
||||||
|
|
||||||
|
sample = {
|
||||||
|
"response": {
|
||||||
|
"GeoObjectCollection": {
|
||||||
|
"featureMember": [
|
||||||
|
{
|
||||||
|
"GeoObject": {
|
||||||
|
"metaDataProperty": {
|
||||||
|
"GeocoderMetaData": {
|
||||||
|
"precision": "exact",
|
||||||
|
"text": (
|
||||||
|
"Россия, Свердловская область, "
|
||||||
|
"Екатеринбург, улица Малышева, 51"
|
||||||
|
),
|
||||||
|
"kind": "house",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "улица Малышева, 51",
|
||||||
|
"Point": {"pos": "60.586155 56.838004"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return sample
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self) -> _FakeClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||||||
|
return _FakeResp()
|
||||||
|
|
||||||
|
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||||||
|
result = await _yandex_reverse(56.8381, 60.5860, api_key="fake")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
# Yandex pos формат: "lon lat" → snapped_lat=56.838004, snapped_lon=60.586155
|
||||||
|
assert abs(result.snapped_lat - 56.838004) < 1e-6
|
||||||
|
assert abs(result.snapped_lon - 60.586155) < 1e-6
|
||||||
|
assert result.precision == "exact"
|
||||||
|
assert result.provider == "yandex"
|
||||||
|
# Address text должен быть очищен от "Россия, Свердловская область"
|
||||||
|
assert "Россия" not in result.address
|
||||||
|
assert "Свердловская область" not in result.address
|
||||||
|
assert "Малышева" in result.address
|
||||||
|
assert "51" in result.address
|
||||||
|
|
||||||
|
|
||||||
|
async def test_yandex_reverse_returns_none_on_empty_results() -> None:
|
||||||
|
"""Empty featureMember → None."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.services.geocoder import _yandex_reverse
|
||||||
|
|
||||||
|
sample = {"response": {"GeoObjectCollection": {"featureMember": []}}}
|
||||||
|
|
||||||
|
class _FakeResp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
def raise_for_status(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def json(self) -> dict:
|
||||||
|
return sample
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self) -> _FakeClient:
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_: object) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get(self, *_: object, **__: object) -> _FakeResp:
|
||||||
|
return _FakeResp()
|
||||||
|
|
||||||
|
with patch.object(httpx, "AsyncClient", lambda *a, **kw: _FakeClient()):
|
||||||
|
result = await _yandex_reverse(56.0, 60.0, api_key="fake")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
@ -13,6 +13,43 @@ const LEAFLET_CSS_SRI = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||||
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
const LEAFLET_JS_SRI = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||||
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
const EKB_CENTER: [number, number] = [56.8389, 60.6057];
|
||||||
|
|
||||||
|
/** Precision значения от reverse endpoint, при которых имеет смысл двигать
|
||||||
|
* marker на snapped point (центр здания от Yandex / нашего FDW). Для
|
||||||
|
* street/range/near/locality marker остаётся на клике — иначе соврём
|
||||||
|
* пользователю, что нашли точное здание. */
|
||||||
|
const SNAP_PRECISIONS = new Set(["exact", "number", "cadastral"]);
|
||||||
|
|
||||||
|
/** Минимальная дистанция click→snap в метрах для перемещения marker'а:
|
||||||
|
* меньше — не двигаем (иначе marker «дёргается» при кликах прямо в дом). */
|
||||||
|
const SNAP_MIN_DISTANCE_M = 5;
|
||||||
|
|
||||||
|
/** Расстояние в метрах между двумя точками (haversine, достаточно точно для <1km). */
|
||||||
|
function haversineMeters(
|
||||||
|
lat1: number,
|
||||||
|
lon1: number,
|
||||||
|
lat2: number,
|
||||||
|
lon2: number,
|
||||||
|
): number {
|
||||||
|
const R = 6_371_000; // м
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(lat2 - lat1);
|
||||||
|
const dLon = toRad(lon2 - lon1);
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) ** 2 +
|
||||||
|
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReverseResponse {
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
snapped_lat: number;
|
||||||
|
snapped_lon: number;
|
||||||
|
precision: string;
|
||||||
|
provider: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Подгружает Leaflet с CDN один раз, резолвит window.L. */
|
/** Подгружает Leaflet с CDN один раз, резолвит window.L. */
|
||||||
function loadLeaflet(): Promise<any> {
|
function loadLeaflet(): Promise<any> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -57,6 +94,10 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
const [address, setAddress] = useState<string | null>(null);
|
const [address, setAddress] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [mapError, setMapError] = useState(false);
|
const [mapError, setMapError] = useState(false);
|
||||||
|
// True если последний reverse snap'нул marker на точку здания. Показываем
|
||||||
|
// тонкий hint «Точка дома по Яндексу», чтобы user понимал почему marker
|
||||||
|
// переехал на пару метров от его клика.
|
||||||
|
const [snapped, setSnapped] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let map: any = null;
|
let map: any = null;
|
||||||
|
|
@ -86,12 +127,43 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
fillOpacity: 0.45,
|
fillOpacity: 0.45,
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
setAddress(null);
|
setAddress(null);
|
||||||
|
setSnapped(false);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_BASE_URL}/api/v1/geocode/reverse?lat=${lat}&lon=${lon}`)
|
fetch(`${API_BASE_URL}/api/v1/geocode/reverse?lat=${lat}&lon=${lon}`)
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
.then((r) => (r.ok ? (r.json() as Promise<ReverseResponse>) : null))
|
||||||
.then((d) => setAddress((d?.address as string) ?? null))
|
.then((d) => {
|
||||||
|
if (cancelled || !d) {
|
||||||
|
setAddress(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddress(d.address);
|
||||||
|
// Snap marker → центр здания если provider дал точное precision
|
||||||
|
// и snapped >5m от клика (иначе скачок незаметен / создаёт jitter).
|
||||||
|
if (
|
||||||
|
SNAP_PRECISIONS.has(d.precision) &&
|
||||||
|
typeof d.snapped_lat === "number" &&
|
||||||
|
typeof d.snapped_lon === "number"
|
||||||
|
) {
|
||||||
|
const dist = haversineMeters(
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
d.snapped_lat,
|
||||||
|
d.snapped_lon,
|
||||||
|
);
|
||||||
|
if (dist >= SNAP_MIN_DISTANCE_M && marker && map) {
|
||||||
|
marker.setLatLng([d.snapped_lat, d.snapped_lon]);
|
||||||
|
map.panTo([d.snapped_lat, d.snapped_lon], {
|
||||||
|
animate: true,
|
||||||
|
duration: 0.3,
|
||||||
|
});
|
||||||
|
setSnapped(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch(() => setAddress(null))
|
.catch(() => setAddress(null))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => setMapError(true));
|
.catch(() => setMapError(true));
|
||||||
|
|
@ -215,6 +287,18 @@ export function MapPicker({ onPick, onClose }: Props) {
|
||||||
: address
|
: address
|
||||||
? address
|
? address
|
||||||
: "Кликните по дому на карте"}
|
: "Кликните по дому на карте"}
|
||||||
|
{address && snapped && !loading ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: "var(--fg-tertiary)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
Точка дома по Яндексу
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue