diff --git a/backend/main.py b/backend/main.py index 315727f..a898bb2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -14,6 +14,7 @@ from backend.routers.cases import router as cases_router from backend.routers.closed_cases import router as closed_cases_router from backend.routers.health import router as health_router from backend.routers.stats import router as stats_router +from backend.routers.water import router as water_router def _parse_origins(value: str) -> list[str]: @@ -51,6 +52,7 @@ app.include_router(cases_router) app.include_router(closed_cases_router) app.include_router(stats_router) app.include_router(admin_router) +app.include_router(water_router) @app.get('/') diff --git a/backend/routers/water.py b/backend/routers/water.py new file mode 100644 index 0000000..43c2ae4 --- /dev/null +++ b/backend/routers/water.py @@ -0,0 +1,269 @@ +"""Водоёмы в зоне поиска — геослой по рекомендациям анализа. + +Рекомендации профилей (scoring_service.BEHAVIORAL_PROFILES) говорят +«водоёмы — критичный тип локаций»: РАС ×3.0 + «перекрыть ВСЕ водоёмы и ж/д», +эпилепсия ×3.5, дети 0–7 лет ×2.2–2.5. Этот эндпоинт отдаёт полигоны водоёмов +(OSM/Overpass) вокруг ТНП случая, чтобы подсветить их на карте зон поиска +(AnalysisResult → SearchMap), а не только называть их текстом. + +Данные: OpenStreetMap, лицензия ODbL — attribution обязателен на карте. +Кэш дисковый как у geo_service; circuit breaker переиспользуем оттуда же, +чтобы анализ и водоёмы отключались синхронно при отсутствии сети. +""" +from __future__ import annotations + +import hashlib +import json +import logging +import math +from pathlib import Path +from typing import Any, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from backend.database import db +from backend.routers.auth import require_roles +from services.geo_service import _overpass_circuit_open, query_overpass + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix='/api/v1/water', tags=['water'], + dependencies=[Depends(require_roles(['operator', 'field', 'admin']))]) + +# Кэш водоёмов — свой TTL (медленнее устаревает, чем дорожные данные): +# гидрография OSM стабильна в масштабе дней. +_CACHE_DIR = '/tmp/overpass_cache/water' +_CACHE_TTL_HOURS = 72 + +# Overpass-теги водных объектов, релевантных для поиска пропавшего. +# natural=water покрывает озера/водохранилища/пруды/реки (полигоны), +# wetland — болота (застревание, сложная проходимость). +_WATER_QUERY_TEMPLATE = """ +[out:json][timeout:25]; +( + way["natural"="water"](around:{radius_m},{lat},{lon}); + relation["natural"="water"](around:{radius_m},{lat},{lon}); + way["natural"="wetland"](around:{radius_m},{lat},{lon}); + relation["natural"="wetland"](around:{radius_m},{lat},{lon}); +); +out geom meta; +""" + +# Порядок = приоритет отрисовки (последний рисуется сверху). +_KIND_STYLE = [ + # (ключ фильтра, значение тега, цвет, заливка) + ('natural', 'wetland', '#4a6a8a', 0.25), + ('natural', 'water', '#2f81f7', 0.35), +] + +_CLASS_WARNING = 'Водоём в зоне поиска — приоритетная проверка (профиль: {profiles})' + + +class WaterFeature(BaseModel): + """Полигон водоёма в GeoJSON-геометрии + атрибуты для попапа.""" + kind: str # water | wetland + name: Optional[str] = None + area_m2: Optional[float] = None + distance_m: Optional[float] = None + geom: dict[str, Any] # GeoJSON Polygon/MultiPolygon (WGS84) + + +class WaterResponse(BaseModel): + case_id: Optional[str] = None + tnp: dict[str, Optional[float]] + radius_m: int + count: int + profiles: list[str] = [] + warning: Optional[str] = None + features: list[WaterFeature] = [] + cached: bool = False + attribution: str = '© OpenStreetMap contributors' + + +def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Расстояние между точками, метры (как в geo_service).""" + r = 6371000.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = p2 - p1 + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def _polygon_centroid_area(ring: list[dict]) -> tuple[float, float, float]: + """(площадь м², средняя широта, средняя долгота) по внешнему кольцу. + + Площадь — формула сферического избытка; для наших масштабов (пруды, + водохранилища в радиусе поиска) достаточно точна для порогов «крупный/мелкий». + """ + n = len(ring) + if n < 3: + return 0.0, 0.0, 0.0 + lat = sum(p['lat'] for p in ring) / n + lon = sum(p['lon'] for p in ring) / n + r = 6371000.0 + # spherical excess (approx, unit sphere × r²) + area = 0.0 + for i in range(n - 1): + p1, p2 = ring[i], ring[i + 1] + area += math.radians(p2['lon'] - p1['lon']) * ( + 2 + math.sin(math.radians(p1['lat'])) + math.sin(math.radians(p2['lat'])) + ) + area = abs(area) * r * r / 2 + return area, lat, lon + + +def _way_to_polygon(element: dict) -> Optional[dict[str, Any]]: + """Замкнутый way → GeoJSON Polygon; незамкнутый — None.""" + geometry = element.get('geometry') or [] + if len(geometry) < 4: + return None + first, last = geometry[0], geometry[-1] + if abs(first['lat'] - last['lat']) > 1e-9 or abs(first['lon'] - last['lon']) > 1e-9: + return None + coords = [[p['lon'], p['lat']] for p in geometry] + return {'type': 'Polygon', 'coordinates': [coords]} + + +def _element_geoms(elements: list[dict]) -> list[tuple[str, Optional[str], dict, float, float, float]]: + """Из ответа Overpass → (kind, name, geom, area_m2, lat, lon).""" + out: list[tuple[str, Optional[str], dict, float, float, float]] = [] + for el in elements: + tags = el.get('tags') or {} + kind = 'wetland' if tags.get('natural') == 'wetland' else 'water' + name = tags.get('name') + if el.get('type') == 'way': + poly = _way_to_polygon(el) + if poly is None: + continue + ring = el['geometry'] + area, lat, lon = _polygon_centroid_area(ring) + out.append((kind, name, poly, area, lat, lon)) + elif el.get('type') == 'relation' and el.get('members'): + # Мультиполигон из внешних колец way-членов; без top-level geometry. + polygons: list[list[list[float]]] = [] + areas = [] + lats, lons = [], [] + for member in el.get('members', []): + if member.get('role') not in ('outer', ''): + continue + mg = member.get('geometry') or [] + if len(mg) < 4: + continue + poly = _way_to_polygon({'geometry': mg}) + if poly is None: + continue + polygons.append(poly['coordinates'][0]) + area, lat, lon = _polygon_centroid_area(mg) + areas.append(area) + lats.append(lat) + lons.append(lon) + if not polygons: + continue + geom: dict[str, Any] = ( + {'type': 'MultiPolygon', 'coordinates': [p for p in polygons]} + if len(polygons) > 1 else + {'type': 'Polygon', 'coordinates': polygons[0]} + ) + out.append((kind, name, geom, sum(areas), sum(lats) / len(lats), sum(lons) / len(lons))) + return out + + +def _cache_get(key: str) -> Optional[dict]: + import os + from datetime import datetime, timedelta + path = os.path.join(_CACHE_DIR, f'{key}.json') + if not os.path.exists(path): + return None + try: + with open(path, 'r', encoding='utf-8') as f: + cached = json.load(f) + ts = datetime.fromisoformat(cached['timestamp']) + if datetime.now() - ts > timedelta(hours=_CACHE_TTL_HOURS): + os.unlink(path) + return None + return cached['data'] + except Exception: + return None + + +def _cache_put(key: str, data: dict) -> None: + import os + from datetime import datetime + try: + os.makedirs(_CACHE_DIR, exist_ok=True) + with open(os.path.join(_CACHE_DIR, f'{key}.json'), 'w', encoding='utf-8') as f: + json.dump({'timestamp': datetime.now().isoformat(), 'data': data}, f, + ensure_ascii=False) + except Exception: + logger.warning('water cache write failed', exc_info=True) + + +def _detect_profiles(case_detail: dict[str, Any]) -> list[str]: + """Профили из диагнозов (та же логика, что analyze._derive_profiles, по detail).""" + profiles: list[str] = [] + for diag in (case_detail.get('diagnosis_type') or []): + d = str(diag).lower() + if 'рас' in d or 'аутизм' in d: + profiles.append('РАС') + elif 'эпилепси' in d: + profiles.append('эпилепсия') + if case_detail.get('cant_swim'): + profiles.append('не_умеет_плавать') + return profiles + + +@router.get('/{case_id}', response_model=WaterResponse) +async def water_for_case(case_id: str) -> WaterResponse: + """Полигоны водоёмов вокруг ТНП случая (для слоя на карте зон поиска).""" + case = db.get_case(str(case_id)) + if case is None: + raise HTTPException(status_code=404, detail='Case not found') + detail = case.to_detail() + lat, lon = detail.get('tnp_lat'), detail.get('tnp_lon') + if lat is None or lon is None: + raise HTTPException(status_code=409, detail='Case has no TNP coordinates (tnp_lat/tnp_lon)') + + profiles = _detect_profiles(detail) + warning = None + if profiles: + warning = _CLASS_WARNING.format(profiles=', '.join(profiles)) + + radius_m = 3000 # перекрывает дефолтную зону анализа (max_distance_km ≤ ~3) + cache_key = hashlib.md5(f'{lat:.5f}:{lon:.5f}:{radius_m}'.encode()).hexdigest() + + cached = _cache_get(cache_key) + if cached is not None: + features_payload = cached + is_cached = True + else: + if _overpass_circuit_open(): + return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon}, + radius_m=radius_m, count=0, profiles=profiles, + features=[], cached=False) + query = _WATER_QUERY_TEMPLATE.format(lat=lat, lon=lon, radius_m=radius_m) + data = await query_overpass(query) + if isinstance(data, list): + elements = data + else: + elements = data.get('elements', []) + features_payload = [] + for kind, name, geom, area, flat, flon in _element_geoms(elements): + features_payload.append({ + 'kind': kind, + 'name': name, + 'area_m2': round(area) if area else None, + 'distance_m': round(_haversine(lat, lon, flat, flon)) if area else None, + 'geom': geom, + }) + # Крупные объекты вперёд, безымянные малые — в хвост. + features_payload.sort(key=lambda f: f['area_m2'] or 0, reverse=True) + if elements: # не кэшируем пустой ответ от трипнутого breaker'а + _cache_put(cache_key, features_payload) + is_cached = False + + features = [WaterFeature(**f) for f in features_payload] + return WaterResponse(case_id=str(case_id), tnp={'lat': lat, 'lon': lon}, + radius_m=radius_m, count=len(features), profiles=profiles, + warning=warning, features=features, cached=is_cached) \ No newline at end of file diff --git a/frontend/src/components/SearchMap.jsx b/frontend/src/components/SearchMap.jsx index bb7a6c3..056fa50 100644 --- a/frontend/src/components/SearchMap.jsx +++ b/frontend/src/components/SearchMap.jsx @@ -11,7 +11,7 @@ L.Icon.Default.mergeOptions({ shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', }); -const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => { +const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => { const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56]; const distance = maxDistance || 5.0; @@ -127,6 +127,30 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => { } ]; + // Слой водоёмов: GeoJSON-полигоны из /api/v1/water/{case_id}. + // Рисуется ПОД зонами (передаётся первым в JSX), поверх тайлов OSM. + const waterGeoJSON = (waterFeatures || []).map((f) => { + // GeoJSON coords [lon, lat] → Leaflet [lat, lon]; Polygon/MultiPolygon. + const toLatLngs = (coords) => coords[0]?.[0]?.length === 2 && typeof coords[0][0] === 'number' + ? coords[0].map((c) => [c[1], c[0]]) // Polygon: rings + : coords.map((poly) => poly[0].map((c) => [c[1], c[0]])); // MultiPolygon + const positions = waterFeatures._geomCache && waterFeatures._geomCache[waterFeatures.indexOf(f)] + ? waterFeatures._geomCache[waterFeatures.indexOf(f)] + : toLatLngs(f.geom.coordinates); + return { feature: f, positions }; + }); + + const waterStyle = (kind) => kind === 'wetland' + ? { color: '#4a6a8a', fillColor: '#4a6a8a', fillOpacity: 0.25, weight: 1, dashArray: '4 3' } + : { color: '#2f81f7', fillColor: '#2f81f7', fillOpacity: 0.35, weight: 1.5 }; + + const fmtArea = (m2) => { + if (m2 == null) return ''; + if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(1)} км²`; + return `${Math.round(m2 / 1000)} тыс. м²`; + }; + const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`); + return ( { style={{ width: '100%', height: '500px', borderRadius: '8px' }} > + {/* Слой водоёмов (под зонами) */} + {waterGeoJSON.map(({ feature, positions }, idx) => ( + + + {feature.name || (feature.kind === 'wetland' ? 'Болото' : 'Водоём')}
+ Тип: {feature.kind === 'wetland' ? 'болото/влажная зона' : 'водоём'}
+ {feature.area_m2 != null && <>Площадь: ~{fmtArea(feature.area_m2)}
} + {feature.distance_m != null && <>От ТНП: ~{fmtDist(feature.distance_m)}} +
+
+ ))} + {/* Рисуем секторы или круги */} {sectors.length > 0 ? ( sectors.map((sector, idx) => ( @@ -201,4 +241,4 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => { ); }; -export default SearchMap; +export default SearchMap; \ No newline at end of file diff --git a/frontend/src/pages/analysis/AnalysisResult.jsx b/frontend/src/pages/analysis/AnalysisResult.jsx index 5b130c3..26b14aa 100644 --- a/frontend/src/pages/analysis/AnalysisResult.jsx +++ b/frontend/src/pages/analysis/AnalysisResult.jsx @@ -33,6 +33,7 @@ const AnalysisResult = () => { const [error, setError] = useState(null); const [caseData, setCaseData] = useState(null); const [analysisData, setAnalysisData] = useState(null); + const [waterData, setWaterData] = useState(null); useEffect(() => { const loadCaseAndAnalyze = async () => { @@ -55,6 +56,14 @@ const AnalysisResult = () => { const analysis = await analyzeResponse.json(); setAnalysisData(analysis); + // 3. Водоёмы вокруг ТНП (слой на карте; 404/409 — нет координат, не ошибка) + try { + const waterResponse = await apiFetch(`/api/v1/water/${caseId}`); + if (waterResponse.ok) setWaterData(await waterResponse.json()); + } catch { + /* водоёмы — опциональный слой, не блокируют анализ */ + } + } catch (err) { console.error("Error loading case:", err); setError(err.message); @@ -262,10 +271,24 @@ const AnalysisResult = () => { {tnpLat && tnpLon && (

Карта зон поиска

+ {waterData?.warning && ( +
+

+ 🚨 {waterData.warning} +

+
+ )} + {waterData?.count > 0 && ( +

+ 💧 Водоёмы в зоне поиска: {waterData.count} + {waterData.warning && ' — приоритетная проверка'} (данные OSM) +

+ )}

Координаты ТНП: {tnpLat}, {tnpLon}

diff --git a/services/geo_service.py b/services/geo_service.py index 0d7e67a..218019e 100644 --- a/services/geo_service.py +++ b/services/geo_service.py @@ -27,7 +27,14 @@ class Zone(BaseModel): # Cache configuration CACHE_DIR = Path("/tmp/overpass_cache") CACHE_TTL_HOURS = 24 -OVERPASS_URL = "https://overpass-api.de/api/interpreter" +# Порядок зеркал: основной сервер имеет AAAA, но на хостах без IPv6-маршрута +# (наш LXC в VPN-контуре) httpx падает на ConnectError ещё до IPv4-фолбэка. +# Поэтому основной — зеркало без AAAA (maps.mail.ru), overpass-api.de — резерв. +OVERPASS_URLS = [ + "https://maps.mail.ru/osm/tools/overpass/api/interpreter", + "https://overpass-api.de/api/interpreter", +] +OVERPASS_URL = OVERPASS_URLS[0] # обратная совместимость для импортов logger = logging.getLogger(__name__) @@ -220,27 +227,31 @@ async def query_overpass(query: str) -> Dict: if _overpass_circuit_open(): return {'elements': []} - # Query API + # Query API — по цепочке зеркал (первый успешный ответ кэшируется) timeout = httpx.Timeout(30.0, connect=OVERPASS_CONNECT_TIMEOUT) async with httpx.AsyncClient(timeout=timeout) as client: - try: - response = await client.post( - OVERPASS_URL, - data={'data': query}, - headers={'Content-Type': 'application/x-www-form-urlencoded'} - ) - response.raise_for_status() - data = response.json() + for overpass_url in OVERPASS_URLS: + try: + response = await client.post( + overpass_url, + data={'data': query}, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + response.raise_for_status() + data = response.json() - # Save to cache - save_to_cache(cache_key, data) + # Save to cache + save_to_cache(cache_key, data) - _reset_overpass_circuit() - return data - except Exception as e: - _record_overpass_failure() - # Return empty result on error - return {'elements': []} + _reset_overpass_circuit() + return data + except Exception as e: + logger.info("Overpass %s недоступен: %s", overpass_url, e) + continue + # Все зеркала недоступны + _record_overpass_failure() + # Return empty result on error + return {'elements': []} def calculate_road_length(elements: List[Dict]) -> float: