diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index cd7ae0e3..015f5608 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -70,11 +70,10 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None: return None -def _fetch_wind_sync(lat: float, lon: float) -> dict | None: - """Синхронный запрос к Open-Meteo Forecast API для данных о ветре. +def _fetch_weather_sync(lat: float, lon: float) -> dict | None: + """Open-Meteo Forecast API — 7-day weather + climate context. - Возвращает доминирующее направление ветра (circular mean по 7 дням) и - максимальную скорость. None если API недоступен. + Free, no API key, JSON by coordinates. Покрывает РФ полностью. """ try: with httpx.Client(timeout=5) as c: @@ -83,28 +82,58 @@ def _fetch_wind_sync(lat: float, lon: float) -> dict | None: params={ "latitude": lat, "longitude": lon, - "daily": "winddirection_10m_dominant,windspeed_10m_max", + "daily": ( + "temperature_2m_max,temperature_2m_min," + "precipitation_sum,uv_index_max," + "winddirection_10m_dominant,windspeed_10m_max" + ), + "timezone": "Europe/Moscow", "forecast_days": 7, }, ) r.raise_for_status() daily = r.json().get("daily", {}) - dirs = daily.get("winddirection_10m_dominant") or [] - speeds = daily.get("windspeed_10m_max") or [] - if not dirs: + if not daily.get("time"): return None + + t_max = daily.get("temperature_2m_max") or [] + t_min = daily.get("temperature_2m_min") or [] + precip = daily.get("precipitation_sum") or [] + uv = daily.get("uv_index_max") or [] + wind_d = daily.get("winddirection_10m_dominant") or [] + wind_s = daily.get("windspeed_10m_max") or [] + # Circular mean направления ветра (vector sum) — избегает jump 359→1 - x = sum(math.cos(math.radians(d)) for d in dirs) - y = sum(math.sin(math.radians(d)) for d in dirs) - dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 + x = sum(math.cos(math.radians(d)) for d in wind_d if d is not None) + y = sum(math.sin(math.radians(d)) for d in wind_d if d is not None) + dominant = (math.degrees(math.atan2(y, x)) + 360) % 360 if wind_d else 0.0 + rose = ["Север", "С-В", "Восток", "Ю-В", "Юг", "Ю-З", "Запад", "С-З"] + wind_label = rose[round(dominant / 45) % 8] + return { - "dominant_direction_deg": round(dominant), - "dominant_direction_label": _wind_label(dominant), - "max_speed_m_s": round(max(speeds), 1) if speeds else None, - "forecast_days": len(dirs), + "forecast_days": len(daily.get("time", [])), + "temperature": { + "min_c": round(min(t_min), 1) if t_min else None, + "max_c": round(max(t_max), 1) if t_max else None, + "avg_max_c": round(sum(t_max) / len(t_max), 1) if t_max else None, + "avg_min_c": round(sum(t_min) / len(t_min), 1) if t_min else None, + }, + "precipitation_total_mm": round(sum(precip), 1) if precip else 0, + "precipitation_days": sum(1 for p in precip if p and p > 0.5), + "uv_index_max": round(max(uv), 1) if uv else None, + "wind": { + "dominant_direction_deg": round(dominant), + "dominant_direction_label": wind_label, + "max_speed_m_s": round(max(wind_s), 1) if wind_s else None, + }, "source": "open-meteo", + "note": ( + "7-day forecast. Для исторических норм и B2B-данных — " + "Yandex Business / Gismeteo (платно)." + ), } - except Exception: + except Exception as e: + logger.warning("weather fetch failed: %s", e) return None @@ -403,8 +432,8 @@ def analyze_parcel( # 8) Air quality — Open-Meteo (best-effort, null при недоступности) air_q = _fetch_air_quality_sync(centroid_lat, centroid_lon) - # 9) Wind — Open-Meteo (best-effort, null при недоступности) - wind_data = _fetch_wind_sync(centroid_lat, centroid_lon) + # 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности) + weather = _fetch_weather_sync(centroid_lat, centroid_lon) # 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев market_trend: dict[str, Any] | None = None @@ -477,6 +506,28 @@ def analyze_parcel( logger.warning("market_trend query failed for %s: %s", cad_num, e) market_trend = None + # 10b) Geology stub — реальные данные требуют ВСЕГЕИ-200/1000 шейпы в PostGIS + karpinsky_url = ( + f"https://www.karpinskyinstitute.ru/ru/gisatlas/web-gisatlas/" + f"?lat={centroid_lat:.6f}&lon={centroid_lon:.6f}&zoom=12" + ) + efgi_url = "https://efgi.ru/" + geology: dict[str, Any] = { + "data_available": False, + "note": ( + "Подробная геология (типы пород, грунты, мощность ОС) " + "доступна только через ВСЕГЕИ-200/1000 шейпы — требуется " + "ручной импорт в PostGIS (multi-day задача). Для drill-down " + "используй внешние ссылки ниже." + ), + "links": { + "karpinsky_webgis": karpinsky_url, + "efgi_federal_registry": efgi_url, + }, + "lat": centroid_lat, + "lon": centroid_lon, + } + return { "cad_num": cad_num, "source": source, @@ -499,6 +550,8 @@ def analyze_parcel( "sources": nearby_noise_sources[:10], }, "air_quality": air_q, - "wind": wind_data, + "weather": weather, + "wind": (weather or {}).get("wind") if weather else None, # backward compat + "geology": geology, "market_trend": market_trend, } diff --git a/frontend/src/components/site-finder/GeologyBlock.tsx b/frontend/src/components/site-finder/GeologyBlock.tsx new file mode 100644 index 00000000..432d6ea8 --- /dev/null +++ b/frontend/src/components/site-finder/GeologyBlock.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { ParcelAnalysisGeology } from "@/types/site-finder"; + +interface Props { + geology: ParcelAnalysisGeology; +} + +export function GeologyBlock({ geology }: Props) { + return ( +
+
+ Геология +
+ +
+ {geology.note} +
+ + {/* Primary CTA */} + + Открыть ВСЕГЕИ ГИС-Атлас → + + + {/* Secondary link */} + + ЕФГИ (требуется ЕСИА) + + +
+ {geology.lat.toFixed(6)}, {geology.lon.toFixed(6)} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/ScoreCard.tsx b/frontend/src/components/site-finder/ScoreCard.tsx index ff7c3f4c..a501a46b 100644 --- a/frontend/src/components/site-finder/ScoreCard.tsx +++ b/frontend/src/components/site-finder/ScoreCard.tsx @@ -6,8 +6,10 @@ import type { ParcelAnalysisNoise, ParcelAnalysisAirQuality, ParcelAnalysisWind, + ParcelAnalysisWeather, } from "@/types/site-finder"; import { MarketTrendBlock } from "./MarketTrendBlock"; +import { GeologyBlock } from "./GeologyBlock"; interface Props { data: ParcelAnalysis; @@ -342,6 +344,130 @@ function WindBlock({ wind }: { wind: ParcelAnalysisWind | null }) { ); } +// ── Weather block ───────────────────────────────────────────────────────────── + +function uvColor(uv: number): string { + if (uv < 3) return "#16a34a"; + if (uv < 6) return "#d97706"; + if (uv < 8) return "#ea580c"; + return "#dc2626"; +} + +function WeatherBlock({ + weather, +}: { + weather: ParcelAnalysisWeather | null | undefined; +}) { + if (!weather) { + return ( +
+
+ Погода / Климат +
+
Прогноз недоступен
+
+ ); + } + + const { temperature: t, wind } = weather; + + return ( +
+
+ Погода / Климат +
+ + {/* Temperature */} +
+
Температура
+
+ {t.min_c != null && t.max_c != null + ? `${t.min_c}…${t.max_c}°C` + : t.min_c != null + ? `от ${t.min_c}°C` + : t.max_c != null + ? `до ${t.max_c}°C` + : "—"} +
+ {(t.avg_min_c != null || t.avg_max_c != null) && ( +
+ ср. {t.avg_min_c != null ? `${t.avg_min_c}` : "?"} + {"/"} + {t.avg_max_c != null ? `${t.avg_max_c}` : "?"} + °C +
+ )} +
+ + {/* Precipitation */} +
+
Осадки
+
+ {weather.precipitation_total_mm} мм + + {" "} + (за {weather.forecast_days} дн.) + +
+
+ {weather.precipitation_days} дн. с осадками +
+
+ + {/* UV */} + {weather.uv_index_max != null && ( +
+
UV макс:
+
+ {weather.uv_index_max} +
+
+ )} + + {/* Wind */} +
+ +
+ {wind.dominant_direction_label} + {wind.max_speed_m_s != null ? ` · до ${wind.max_speed_m_s} м/с` : ""} +
+
+ + {/* Source + note */} +
+ {weather.source} ·{" "} + {weather.note.length > 60 + ? weather.note.slice(0, 60) + "…" + : weather.note} +
+
+ ); +} + // ── ScoreCard ───────────────────────────────────────────────────────────────── export function ScoreCard({ data }: Props) { @@ -354,7 +480,8 @@ export function ScoreCard({ data }: Props) { const hasEnvironmental = data.noise !== undefined || data.air_quality !== undefined || - data.wind !== undefined; + data.wind !== undefined || + data.weather !== undefined; return (
- + {data.weather !== undefined ? ( + + ) : ( + + )}
)} @@ -582,6 +713,30 @@ export function ScoreCard({ data }: Props) { )} + + {/* Geology */} + {data.geology !== undefined && ( +
+
+ Геология +
+ +
+ )} ); } diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 4fe1ac69..46f45e69 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -55,6 +55,33 @@ export interface ParcelAnalysisPoi { lon: number; } +export interface ParcelAnalysisWeather { + forecast_days: number; + temperature: { + min_c: number | null; + max_c: number | null; + avg_max_c: number | null; + avg_min_c: number | null; + }; + precipitation_total_mm: number; + precipitation_days: number; + uv_index_max: number | null; + wind: ParcelAnalysisWind; + source: string; + note: string; +} + +export interface ParcelAnalysisGeology { + data_available: boolean; + note: string; + links: { + karpinsky_webgis: string; + efgi_federal_registry: string; + }; + lat: number; + lon: number; +} + export interface MarketTrend { recent_avg_price_per_m2: number; prior_avg_price_per_m2: number; @@ -81,6 +108,8 @@ export interface ParcelAnalysis { noise: ParcelAnalysisNoise | null; air_quality: ParcelAnalysisAirQuality | null; wind: ParcelAnalysisWind | null; + weather?: ParcelAnalysisWeather | null; + geology?: ParcelAnalysisGeology; } export type PoiCategory =