feat(site-finder): v3.5 - seasonal weather + hydrology + geotech risk
This commit is contained in:
parent
7bb51aa838
commit
2855a01ca7
7 changed files with 705 additions and 1 deletions
|
|
@ -70,6 +70,82 @@ def _fetch_air_quality_sync(lat: float, lon: float) -> dict | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_seasonal_weather_sync(lat: float, lon: float) -> dict | None:
|
||||||
|
"""Open-Meteo Climate API — 30-летние нормали по сезонам.
|
||||||
|
|
||||||
|
Использует модель MRI-AGCM3.2-S (японская, точная для всего мира).
|
||||||
|
Группирует 1995-2024 по четырём сезонам. Медленнее прогноза — timeout 15s.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=15) as c:
|
||||||
|
r = c.get(
|
||||||
|
"https://climate-api.open-meteo.com/v1/climate",
|
||||||
|
params={
|
||||||
|
"latitude": lat,
|
||||||
|
"longitude": lon,
|
||||||
|
"start_date": "1995-01-01",
|
||||||
|
"end_date": "2024-12-31",
|
||||||
|
"models": "MRI_AGCM3_2_S",
|
||||||
|
"daily": "temperature_2m_max,temperature_2m_min,precipitation_sum",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
daily = data.get("daily", {})
|
||||||
|
times = daily.get("time") or []
|
||||||
|
t_max = daily.get("temperature_2m_max") or []
|
||||||
|
t_min = daily.get("temperature_2m_min") or []
|
||||||
|
precip = daily.get("precipitation_sum") or []
|
||||||
|
if not times:
|
||||||
|
return None
|
||||||
|
|
||||||
|
seasons_months = {
|
||||||
|
"winter": [12, 1, 2],
|
||||||
|
"spring": [3, 4, 5],
|
||||||
|
"summer": [6, 7, 8],
|
||||||
|
"autumn": [9, 10, 11],
|
||||||
|
}
|
||||||
|
buckets: dict[str, dict[str, list[float]]] = {
|
||||||
|
k: {"t_max": [], "t_min": [], "precip": []} for k in seasons_months
|
||||||
|
}
|
||||||
|
for i, t in enumerate(times):
|
||||||
|
month = int(t[5:7]) # 'YYYY-MM-DD'
|
||||||
|
for season, months in seasons_months.items():
|
||||||
|
if month in months:
|
||||||
|
if i < len(t_max) and t_max[i] is not None:
|
||||||
|
buckets[season]["t_max"].append(t_max[i])
|
||||||
|
if i < len(t_min) and t_min[i] is not None:
|
||||||
|
buckets[season]["t_min"].append(t_min[i])
|
||||||
|
if i < len(precip) and precip[i] is not None:
|
||||||
|
buckets[season]["precip"].append(precip[i])
|
||||||
|
break
|
||||||
|
|
||||||
|
seasons: dict[str, Any] = {}
|
||||||
|
for season, vals in buckets.items():
|
||||||
|
if not vals["t_max"]:
|
||||||
|
seasons[season] = None
|
||||||
|
continue
|
||||||
|
seasons[season] = {
|
||||||
|
"avg_t_max_c": round(sum(vals["t_max"]) / len(vals["t_max"]), 1),
|
||||||
|
"avg_t_min_c": round(sum(vals["t_min"]) / len(vals["t_min"]), 1),
|
||||||
|
"max_t_c": round(max(vals["t_max"]), 1),
|
||||||
|
"min_t_c": round(min(vals["t_min"]), 1),
|
||||||
|
"avg_precip_per_day_mm": round(sum(vals["precip"]) / len(vals["precip"]), 1),
|
||||||
|
"total_precip_mm": round(sum(vals["precip"]), 0),
|
||||||
|
"days_observed": len(vals["t_max"]),
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"seasons": seasons,
|
||||||
|
"period": "1995-2024 (30 лет)",
|
||||||
|
"model": "MRI-AGCM3-2-S",
|
||||||
|
"source": "open-meteo-climate",
|
||||||
|
"note": ("Климатические нормали. " "Текущая погода — отдельный API."),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("seasonal weather fetch failed: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
|
def _fetch_weather_sync(lat: float, lon: float) -> dict | None:
|
||||||
"""Open-Meteo Forecast API — 7-day weather + climate context.
|
"""Open-Meteo Forecast API — 7-day weather + climate context.
|
||||||
|
|
||||||
|
|
@ -165,6 +241,71 @@ _POI_WEIGHTS: dict[str, float] = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Сейсмика по ОСР-2016 карта B (среднее повторяемое за 500 лет).
|
||||||
|
# Добавляй регионы по мере расширения географии продукта.
|
||||||
|
GEOTECH_BY_REGION: dict[int, dict[str, Any]] = {
|
||||||
|
66: { # Свердловская обл.
|
||||||
|
"seismic_intensity_balls": 5,
|
||||||
|
"seismic_label": "минимальная сейсмика (≤5 баллов)",
|
||||||
|
"seismic_description": "Обычное строительство без специальных мер.",
|
||||||
|
"permafrost": False,
|
||||||
|
},
|
||||||
|
77: { # Москва
|
||||||
|
"seismic_intensity_balls": 4,
|
||||||
|
"seismic_label": "практически нет сейсмики",
|
||||||
|
"seismic_description": "Обычное строительство.",
|
||||||
|
"permafrost": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _geotech_risk(region_code: int, db: Session, geom_wkt: str) -> dict[str, Any]:
|
||||||
|
"""Геотехнические риски: сейсмика (ОСР-2016) + промышленная близость.
|
||||||
|
|
||||||
|
industrial_within_500m — proxy для возможного загрязнения почв (без
|
||||||
|
реальных шейпов зон загрязнения). Точная геология/гидрогеология требует
|
||||||
|
инженерных изысканий (bore-holes).
|
||||||
|
"""
|
||||||
|
region_data: dict[str, Any] = GEOTECH_BY_REGION.get(
|
||||||
|
region_code,
|
||||||
|
{
|
||||||
|
"seismic_intensity_balls": None,
|
||||||
|
"seismic_label": "нет данных в встроенной таблице",
|
||||||
|
"seismic_description": "ОСР-2016: уточняйте по карте.",
|
||||||
|
"permafrost": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
industrial_close: int = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT COUNT(*) AS n
|
||||||
|
FROM osm_noise_sources_ekb
|
||||||
|
WHERE source_type = 'industrial'
|
||||||
|
AND ST_DWithin(
|
||||||
|
geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
500
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"wkt": geom_wkt},
|
||||||
|
).scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**region_data,
|
||||||
|
"industrial_within_500m": int(industrial_close),
|
||||||
|
"industrial_contamination_flag": int(industrial_close) > 0,
|
||||||
|
"note": (
|
||||||
|
"Сейсмика — ОСР-2016 карта B (среднее повторяемое за 500 лет). "
|
||||||
|
"Для строительства обычно достаточно ≤7 баллов без спецмер. "
|
||||||
|
"Industrial proximity — proxy для возможного загрязнения почв. "
|
||||||
|
"Точная геология/гидрогеология — требует bore-holes (изыскания)."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search", response_model=ParcelSearchResponse)
|
@router.post("/search", response_model=ParcelSearchResponse)
|
||||||
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
async def search_parcels(payload: ParcelSearchRequest) -> ParcelSearchResponse:
|
||||||
"""Search parcels by filters + scoring.
|
"""Search parcels by filters + scoring.
|
||||||
|
|
@ -435,6 +576,58 @@ def analyze_parcel(
|
||||||
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
# 9) Weather — Open-Meteo 7-day forecast (best-effort, null при недоступности)
|
||||||
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
|
weather = _fetch_weather_sync(centroid_lat, centroid_lon)
|
||||||
|
|
||||||
|
# 9b) Seasonal weather — 30-летние нормали Climate API
|
||||||
|
seasonal_weather = _fetch_seasonal_weather_sync(centroid_lat, centroid_lon)
|
||||||
|
|
||||||
|
# 9c) Hydrology — водоёмы и реки в радиусе 2 км из osm_noise_sources_ekb
|
||||||
|
hydrology: dict[str, Any] | None = None
|
||||||
|
try:
|
||||||
|
hydro_rows = (
|
||||||
|
db.execute(
|
||||||
|
text("""
|
||||||
|
SELECT source_type, road_class, name,
|
||||||
|
ST_Distance(
|
||||||
|
n.geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography
|
||||||
|
) AS distance_m
|
||||||
|
FROM osm_noise_sources_ekb n
|
||||||
|
WHERE source_type = 'water'
|
||||||
|
AND ST_DWithin(
|
||||||
|
n.geom::geography,
|
||||||
|
ST_Centroid(ST_GeomFromText(:wkt, 4326))::geography,
|
||||||
|
2000
|
||||||
|
)
|
||||||
|
ORDER BY distance_m ASC
|
||||||
|
LIMIT 10
|
||||||
|
"""),
|
||||||
|
{"wkt": geom_wkt},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
hydrology = {
|
||||||
|
"nearest": [
|
||||||
|
{
|
||||||
|
"subtype": r["road_class"],
|
||||||
|
"name": r["name"],
|
||||||
|
"distance_m": round(float(r["distance_m"])),
|
||||||
|
}
|
||||||
|
for r in hydro_rows[:5]
|
||||||
|
],
|
||||||
|
"flood_risk_flag": any(
|
||||||
|
float(r["distance_m"]) < 200 and r["road_class"] in ("river", "canal")
|
||||||
|
for r in hydro_rows
|
||||||
|
),
|
||||||
|
"note": (
|
||||||
|
"Пойма реки (<200м) — повышенный риск подтопления. Точные данные о "
|
||||||
|
"зонах затопления — в Росреестре (ЗОУИТ типа 33: 'Зона затопления, "
|
||||||
|
"подтопления') через ФГИС ТП."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("hydrology query failed for %s: %s", cad_num, e)
|
||||||
|
hydrology = None
|
||||||
|
|
||||||
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
# 10) Market trend — динамика цен ДДУ в радиусе 3 км за 6 vs предыдущие 6 месяцев
|
||||||
market_trend: dict[str, Any] | None = None
|
market_trend: dict[str, Any] | None = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -551,7 +744,10 @@ def analyze_parcel(
|
||||||
},
|
},
|
||||||
"air_quality": air_q,
|
"air_quality": air_q,
|
||||||
"weather": weather,
|
"weather": weather,
|
||||||
|
"seasonal_weather": seasonal_weather,
|
||||||
"wind": (weather or {}).get("wind") if weather else None, # backward compat
|
"wind": (weather or {}).get("wind") if weather else None, # backward compat
|
||||||
"geology": geology,
|
"geology": geology,
|
||||||
|
"hydrology": hydrology,
|
||||||
|
"geotech_risk": _geotech_risk(66, db, geom_wkt),
|
||||||
"market_trend": market_trend,
|
"market_trend": market_trend,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ _NOISE_QUERIES: list[tuple[str, str, str, str | None]] = [
|
||||||
("railway", "rail", "railway", None),
|
("railway", "rail", "railway", None),
|
||||||
("landuse", "industrial", "industrial", None),
|
("landuse", "industrial", "industrial", None),
|
||||||
("aeroway", "aerodrome", "aerodrome", None),
|
("aeroway", "aerodrome", "aerodrome", None),
|
||||||
|
# Гидрология — реки, ручьи, каналы (way-линии) и водоёмы (way-полигоны)
|
||||||
|
("waterway", "river", "water", "river"),
|
||||||
|
("waterway", "stream", "water", "stream"),
|
||||||
|
("waterway", "canal", "water", "canal"),
|
||||||
|
("natural", "water", "water", "lake_or_pond"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,6 +109,21 @@ def _way_to_linestring_wkt(nodes: list[dict]) -> str | None:
|
||||||
return f"LINESTRING({', '.join(points)})"
|
return f"LINESTRING({', '.join(points)})"
|
||||||
|
|
||||||
|
|
||||||
|
def _way_to_polygon_wkt(nodes: list[dict]) -> str | None:
|
||||||
|
"""Построить WKT Polygon из замкнутого way Overpass (out geom).
|
||||||
|
|
||||||
|
Используется для natural=water (озёра, пруды). Если кольцо не замкнуто
|
||||||
|
автоматически — добавляем первую точку в конец.
|
||||||
|
"""
|
||||||
|
points = [f"{n['lon']} {n['lat']}" for n in nodes if "lon" in n and "lat" in n]
|
||||||
|
if len(points) < 3:
|
||||||
|
return None
|
||||||
|
# Замкнуть кольцо если нужно
|
||||||
|
if points[0] != points[-1]:
|
||||||
|
points.append(points[0])
|
||||||
|
return f"POLYGON(({', '.join(points)}))"
|
||||||
|
|
||||||
|
|
||||||
def sync_noise_sources_to_db() -> dict[str, int]:
|
def sync_noise_sources_to_db() -> dict[str, int]:
|
||||||
"""UPSERT шумовых источников из Overpass в osm_noise_sources_ekb.
|
"""UPSERT шумовых источников из Overpass в osm_noise_sources_ekb.
|
||||||
|
|
||||||
|
|
@ -135,7 +155,14 @@ def sync_noise_sources_to_db() -> dict[str, int]:
|
||||||
geom_params: dict
|
geom_params: dict
|
||||||
if osm_type == "way":
|
if osm_type == "way":
|
||||||
nodes: list[dict] = el.get("geometry") or []
|
nodes: list[dict] = el.get("geometry") or []
|
||||||
wkt = _way_to_linestring_wkt(nodes)
|
# natural=water → замкнутый полигон; waterway → линия
|
||||||
|
if source_type == "water" and road_class == "lake_or_pond":
|
||||||
|
wkt = _way_to_polygon_wkt(nodes)
|
||||||
|
if wkt is None:
|
||||||
|
# Fallback: попробовать как линию (частично загруженный bbox-обрезок)
|
||||||
|
wkt = _way_to_linestring_wkt(nodes)
|
||||||
|
else:
|
||||||
|
wkt = _way_to_linestring_wkt(nodes)
|
||||||
if wkt is None:
|
if wkt is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
131
frontend/src/components/site-finder/GeotechRiskBlock.tsx
Normal file
131
frontend/src/components/site-finder/GeotechRiskBlock.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { GeotechRisk } from "@/types/site-finder";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
risk: GeotechRisk;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seismicBadgeStyle(balls: number): { bg: string; color: string } {
|
||||||
|
if (balls <= 5) return { bg: "#dcfce7", color: "#15803d" };
|
||||||
|
if (balls <= 7) return { bg: "#fef3c7", color: "#92400e" };
|
||||||
|
return { bg: "#fecaca", color: "#b91c1c" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeotechRiskBlock({ risk }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#fafafa",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Геотехнический риск
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Seismics */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{risk.seismic_intensity_balls !== null ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: "50%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 800,
|
||||||
|
flexShrink: 0,
|
||||||
|
...seismicBadgeStyle(risk.seismic_intensity_balls),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{risk.seismic_intensity_balls}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, color: "#374151" }}>
|
||||||
|
{risk.seismic_label}
|
||||||
|
</div>
|
||||||
|
{risk.seismic_description && (
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||||
|
{risk.seismic_description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#6b7280",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{risk.seismic_label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permafrost */}
|
||||||
|
{risk.permafrost && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#374151",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>🟡</span>
|
||||||
|
<span>Вечная мерзлота — особые фундаменты</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Industrial contamination */}
|
||||||
|
{risk.industrial_contamination_flag && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "#fff7ed",
|
||||||
|
border: "1px solid #fdba74",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#c2410c",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>⚠</span>
|
||||||
|
<span>
|
||||||
|
{risk.industrial_within_500m} промзон(ы) в <500м (риск
|
||||||
|
загрязнения почв)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{risk.note && (
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>{risk.note}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
frontend/src/components/site-finder/HydrologyBlock.tsx
Normal file
109
frontend/src/components/site-finder/HydrologyBlock.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { Hydrology, HydrologyNearest } from "@/types/site-finder";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hydrology: Hydrology;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUBTYPE_ICON: Record<string, string> = {
|
||||||
|
river: "🏞",
|
||||||
|
stream: "💦",
|
||||||
|
lake_or_pond: "🪷",
|
||||||
|
canal: "🚣",
|
||||||
|
};
|
||||||
|
|
||||||
|
function subtypeLabel(subtype: HydrologyNearest["subtype"]): string {
|
||||||
|
switch (subtype) {
|
||||||
|
case "river":
|
||||||
|
return "река";
|
||||||
|
case "stream":
|
||||||
|
return "ручей";
|
||||||
|
case "lake_or_pond":
|
||||||
|
return "озеро/пруд";
|
||||||
|
case "canal":
|
||||||
|
return "канал";
|
||||||
|
default:
|
||||||
|
return "водоём";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HydrologyBlock({ hydrology }: Props) {
|
||||||
|
const top5 = hydrology.nearest.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f0f9ff",
|
||||||
|
border: "1px solid #bae6fd",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#0369a1" }}>
|
||||||
|
Гидрология
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flood risk banner */}
|
||||||
|
{hydrology.flood_risk_flag && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "#fecaca",
|
||||||
|
border: "1px solid #f87171",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#b91c1c",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>⚠</span>
|
||||||
|
<span>Возможная пойма — река/канал в <200м</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Nearest water bodies */}
|
||||||
|
{top5.length === 0 ? (
|
||||||
|
<div style={{ fontSize: 13, color: "#6b7280" }}>
|
||||||
|
Поблизости водных объектов нет
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
|
||||||
|
{top5.map((item, i) => {
|
||||||
|
const icon = SUBTYPE_ICON[item.subtype ?? ""] ?? "💧";
|
||||||
|
const label = subtypeLabel(item.subtype);
|
||||||
|
const name = item.name ?? "б/н";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#374151",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{icon}</span>
|
||||||
|
<span>
|
||||||
|
{name} {label} — {Math.round(item.distance_m)} м
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note */}
|
||||||
|
{hydrology.note && (
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>{hydrology.note}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@ import type {
|
||||||
} from "@/types/site-finder";
|
} from "@/types/site-finder";
|
||||||
import { MarketTrendBlock } from "./MarketTrendBlock";
|
import { MarketTrendBlock } from "./MarketTrendBlock";
|
||||||
import { GeologyBlock } from "./GeologyBlock";
|
import { GeologyBlock } from "./GeologyBlock";
|
||||||
|
import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock";
|
||||||
|
import { HydrologyBlock } from "./HydrologyBlock";
|
||||||
|
import { GeotechRiskBlock } from "./GeotechRiskBlock";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: ParcelAnalysis;
|
data: ParcelAnalysis;
|
||||||
|
|
@ -714,6 +717,42 @@ export function ScoreCard({ data }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Seasonal weather */}
|
||||||
|
{"seasonal_weather" in data && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px 16px",
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SeasonalWeatherBlock seasonal={data.seasonal_weather} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hydrology */}
|
||||||
|
{data.hydrology !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px 16px",
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HydrologyBlock hydrology={data.hydrology} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Geotech risk */}
|
||||||
|
{data.geotech_risk !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "12px 24px 16px",
|
||||||
|
borderTop: "1px solid #e5e7eb",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<GeotechRiskBlock risk={data.geotech_risk} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Geology */}
|
{/* Geology */}
|
||||||
{data.geology !== undefined && (
|
{data.geology !== undefined && (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
154
frontend/src/components/site-finder/SeasonalWeatherBlock.tsx
Normal file
154
frontend/src/components/site-finder/SeasonalWeatherBlock.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { SeasonalWeather, SeasonStats } from "@/types/site-finder";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
seasonal: SeasonalWeather | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEASON_CONFIG: Array<{
|
||||||
|
key: keyof SeasonalWeather["seasons"];
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "winter", label: "Зима", bg: "#dbeafe", color: "#1e40af" },
|
||||||
|
{ key: "spring", label: "Весна", bg: "#dcfce7", color: "#15803d" },
|
||||||
|
{ key: "summer", label: "Лето", bg: "#fef3c7", color: "#92400e" },
|
||||||
|
{ key: "autumn", label: "Осень", bg: "#fed7aa", color: "#c2410c" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function SeasonCard({
|
||||||
|
label,
|
||||||
|
bg,
|
||||||
|
color,
|
||||||
|
stats,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
bg: string;
|
||||||
|
color: string;
|
||||||
|
stats: SeasonStats;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "12px 14px",
|
||||||
|
background: bg,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
flex: "1 1 0",
|
||||||
|
minWidth: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color }}>{label}</div>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 700, color: "#111827" }}>
|
||||||
|
{stats.avg_t_min_c}…{stats.avg_t_max_c}°C
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#6b7280" }}>
|
||||||
|
(макс ↓{stats.min_t_c} / ↑{stats.max_t_c}°C)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#374151" }}>
|
||||||
|
{stats.total_precip_mm} мм осадков
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SeasonalWeatherBlock({ seasonal }: Props) {
|
||||||
|
if (!seasonal) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Климат (нормали)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: "#9ca3af" }}>
|
||||||
|
Климат-данные недоступны
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "14px 16px",
|
||||||
|
background: "#fafafa",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 8,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700, color: "#374151" }}>
|
||||||
|
Климат (нормали)
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>{seasonal.period}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Season cards */}
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{SEASON_CONFIG.map(({ key, label, bg, color }) => {
|
||||||
|
const stats = seasonal.seasons[key];
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: "12px 14px",
|
||||||
|
background: "#f3f4f6",
|
||||||
|
flex: "1 1 0",
|
||||||
|
minWidth: 120,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: 13, fontWeight: 700, color: "#9ca3af" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af", marginTop: 4 }}>
|
||||||
|
Нет данных
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SeasonCard
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
bg={bg}
|
||||||
|
color={color}
|
||||||
|
stats={stats}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ fontSize: 11, color: "#9ca3af" }}>
|
||||||
|
{seasonal.source} · {seasonal.model}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -82,6 +82,51 @@ export interface ParcelAnalysisGeology {
|
||||||
lon: number;
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SeasonStats {
|
||||||
|
avg_t_max_c: number;
|
||||||
|
avg_t_min_c: number;
|
||||||
|
max_t_c: number;
|
||||||
|
min_t_c: number;
|
||||||
|
avg_precip_per_day_mm: number;
|
||||||
|
total_precip_mm: number;
|
||||||
|
days_observed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeasonalWeather {
|
||||||
|
seasons: {
|
||||||
|
winter: SeasonStats | null;
|
||||||
|
spring: SeasonStats | null;
|
||||||
|
summer: SeasonStats | null;
|
||||||
|
autumn: SeasonStats | null;
|
||||||
|
};
|
||||||
|
period: string;
|
||||||
|
model: string;
|
||||||
|
source: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HydrologyNearest {
|
||||||
|
subtype: "river" | "stream" | "lake_or_pond" | "canal" | null;
|
||||||
|
name: string | null;
|
||||||
|
distance_m: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Hydrology {
|
||||||
|
nearest: HydrologyNearest[];
|
||||||
|
flood_risk_flag: boolean;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeotechRisk {
|
||||||
|
seismic_intensity_balls: number | null;
|
||||||
|
seismic_label: string;
|
||||||
|
seismic_description: string;
|
||||||
|
permafrost: boolean;
|
||||||
|
industrial_within_500m: number;
|
||||||
|
industrial_contamination_flag: boolean;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MarketTrend {
|
export interface MarketTrend {
|
||||||
recent_avg_price_per_m2: number;
|
recent_avg_price_per_m2: number;
|
||||||
prior_avg_price_per_m2: number;
|
prior_avg_price_per_m2: number;
|
||||||
|
|
@ -109,6 +154,9 @@ export interface ParcelAnalysis {
|
||||||
air_quality: ParcelAnalysisAirQuality | null;
|
air_quality: ParcelAnalysisAirQuality | null;
|
||||||
wind: ParcelAnalysisWind | null;
|
wind: ParcelAnalysisWind | null;
|
||||||
weather?: ParcelAnalysisWeather | null;
|
weather?: ParcelAnalysisWeather | null;
|
||||||
|
seasonal_weather?: SeasonalWeather | null;
|
||||||
|
hydrology?: Hydrology;
|
||||||
|
geotech_risk?: GeotechRisk;
|
||||||
geology?: ParcelAnalysisGeology;
|
geology?: ParcelAnalysisGeology;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue